( function ( w, $ ) { 'use strict'; var config = w.wpbc_appointment_services_config || {}; var state = { storageReady: false, catalog_loading: true, selectedId: Number( config.selected_id || 0 ), requested_focus: String( config.focus_section || '' ), focus_handled: false, busy: false, status: 'all', services: [], providers: {}, providerCount: 0, editor_snapshot: '', editor_request_sequence: 0, initial_selection_pending: 0 < Number( config.selected_id || 0 ), inspector_focus_target: null, mutation_in_progress: false, operation_mode: '', operation_review: null, operation_request_sequence: 0, inline_editing: false, inline_drafts: {}, inline_schema: {}, inline_schema_loading: false, inline_request_sequence: 0, last_response: null, page: 1, page_size: Number( config.catalog && config.catalog.initial_request && config.catalog.initial_request.items_per_page ? config.catalog.initial_request.items_per_page : 10 ), total_items: 0, total_pages: 0, sort_by: String( config.catalog && config.catalog.initial_request && config.catalog.initial_request.sort_by ? config.catalog.initial_request.sort_by : 'service_id' ), sort_order: String( config.catalog && config.catalog.initial_request && config.catalog.initial_request.sort_order ? config.catalog.initial_request.sort_order : 'desc' ) }; var catalogController = false; var inlineWorkflowController = false; var inlineReviewWorkflowController = false; var deleteReviewWorkflowController = false; var inspectorWorkflowController = false; var searchTimer = 0; var weekdayKeys = [ 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' ]; var pending_highlight_ids = []; /** Extract an API message while preserving a caller-provided fallback. */ function messageFrom( response, fallback ) { return response && response.data && response.data.message ? response.data.message : fallback; } /** Display a shared Booking Calendar administrator notice. */ function notify( message, type ) { if ( message && typeof w.wpbc_admin_show_message === 'function' ) { w.wpbc_admin_show_message( message, type || 'info', 5000, false ); } } /** * Return the shared signed-review presentation controller. * * @return {Object|false} Shared review controller or false when unavailable. */ function get_inline_review_workflow() { if ( inlineReviewWorkflowController ) { return inlineReviewWorkflowController; } if ( ! w.wpbc_ui_catalog || 'function' !== typeof w.wpbc_ui_catalog.create_inline_review_workflow ) { return false; } inlineReviewWorkflowController = w.wpbc_ui_catalog.create_inline_review_workflow( { apply_selector: '.wpbc_appointment_services__operation_apply', cancel_selector: '.wpbc_appointment_services__cancel', root: document } ); return inlineReviewWorkflowController; } /** * Return the shared permanent-deletion presentation controller. * * @return {Object|false} Shared deletion controller or false when unavailable. */ function get_delete_review_workflow() { if ( deleteReviewWorkflowController ) { return deleteReviewWorkflowController; } if ( ! w.wpbc_ui_catalog || 'function' !== typeof w.wpbc_ui_catalog.create_delete_review_workflow ) { return false; } deleteReviewWorkflowController = w.wpbc_ui_catalog.create_delete_review_workflow( { acknowledgement_selector: '[data-wpbc-ui-catalog-delete-acknowledgement]', apply_selector: '.wpbc_appointment_services__operation_apply', cancel_selector: '.wpbc_appointment_services__cancel', root: document } ); return deleteReviewWorkflowController; } /** * Show the native Booking Calendar Processing notice. * * The returned element identifies this specific request's notice so * overlapping requests cannot dismiss each other's feedback. * * @return {jQuery} Processing notice wrapper, or an empty collection. */ function show_processing_notice() { if ( 'function' !== typeof w.wpbc_admin_show_message_processing ) { return $(); } w.wpbc_admin_show_message_processing( '' ); return $( '#ajax_working .wpbc_processing.wpbc_spin' ).last().closest( '[id^="wpbc_notice_"]' ); } /** * Hide the Processing notice created for one completed request. * * @param {jQuery} $processing_notice Notice wrapper returned by show_processing_notice(). * @return {void} */ function hide_processing_notice( $processing_notice ) { if ( $processing_notice && $processing_notice.length ) { $processing_notice.stop( true, true ).hide(); } } /** * Send an authenticated Appointment Services AJAX request. * * Requests normally use the shared administrator Processing notice and remove * only their own notice after settling. Inspector-loading requests may opt out * because the shared inspector already exposes equivalent progress feedback. * * @param {string} action WordPress AJAX action name. * @param {Object} data Request-specific payload. * @param {boolean} use_processing_notice Whether to show the global notice. * @return {jqXHR} jQuery AJAX promise for the request. */ function request( action, data, use_processing_notice ) { var $processing_notice = false === use_processing_notice ? $() : show_processing_notice(); return $.ajax( { url: config.ajax_url, type: 'POST', dataType: 'json', data: $.extend( { action: action, nonce: config.nonce }, data || {} ) } ) .always( function () { hide_processing_notice( $processing_notice ); } ); } /** Activate the Settings or Help panel selected in the right sidebar. */ function switchRightPanel( $tab ) { var panelId = $tab.attr( 'aria-controls' ); var panel = panelId ? document.getElementById( panelId ) : null; var $tabs = $tab.closest( '.wpbc_appointment_services__rightbar_tabs' ).find( '[role="tab"]' ); var $panels = $( '.wpbc_appointment_services__rightbar [role="tabpanel"]' ); if ( ! panel ) { return; } $tabs.attr( 'aria-selected', 'false' ); $tab.attr( 'aria-selected', 'true' ); $panels.prop( 'hidden', true ).attr( 'aria-hidden', 'true' ); $( panel ).prop( 'hidden', false ).attr( 'aria-hidden', 'false' ); updateControls(); } /** * Tell the active Setup Wizard bar that the page workspace changed width. * * The delayed notification runs after the shared sidebar transition so the * setup bar can measure the final inspector boundary. * * @return {void} */ function notify_setup_wizard_layout_changed() { $( document ).trigger( 'wpbc_setup_wizard_layout_changed' ); w.setTimeout( function () { $( document ).trigger( 'wpbc_setup_wizard_layout_changed' ); }, 300 ); } /** * Expand the right sidebar and display the Service Settings inspector. * * @return {void} */ function expand_service_inspector() { var $settings_tab = $( '#wpbc_tab_service_settings' ); if ( 'function' === typeof w.wpbc_admin_ui__sidebar_right__do_max ) { w.wpbc_admin_ui__sidebar_right__do_max(); } if ( $settings_tab.length ) { switchRightPanel( $settings_tab ); } notify_setup_wizard_layout_changed(); } /** * Reveal, expand, highlight, and optionally focus one Service editor group. * * @param {string} fields_selector Group fields selector from the fixed editor markup. * @param {string} focus_selector Optional control selector to focus after scrolling. * @return {boolean} True when the requested inspector group was found. */ function open_service_inspector_group( fields_selector, focus_selector ) { var $group_fields = $( fields_selector ); var $group = $group_fields.closest( '.wpbc_ui__collapsible_group' ); var $group_header = $group.children( '.group__header' ); var group_element = $group.get( 0 ); var focus_element = focus_selector ? document.querySelector( focus_selector ) : null; expand_service_inspector(); if ( ! $group.length ) { return false; } $group.addClass( 'is-open' ); $group_header.attr( 'aria-expanded', 'true' ); $group_fields.prop( 'hidden', false ).attr( 'aria-hidden', 'false' ); $group.removeClass( 'wpbc_appointment_services__focus_pulse' ); if ( group_element ) { void group_element.offsetWidth; $group.addClass( 'wpbc_appointment_services__focus_pulse' ); try { group_element.scrollIntoView( { behavior: 'smooth', block: 'start', inline: 'nearest' } ); } catch ( error ) { group_element.scrollIntoView( true ); } w.setTimeout( function () { $group.removeClass( 'wpbc_appointment_services__focus_pulse' ); }, 900 ); } if ( focus_element && typeof focus_element.focus === 'function' ) { try { focus_element.focus( { preventScroll: true } ); } catch ( error ) { focus_element.focus(); } } return true; } /** * Open, reveal, and highlight the General Service editor section. * * @return {void} */ function open_add_service_inspector() { open_service_inspector_group( '#wpbc_service_general', '[data-service-field="title"]' ); } /** * Apply the one-time inspector focus requested by an administration link. * * @return {void} */ function focus_requested_service_section() { if ( state.focus_handled || 'booking_form' !== state.requested_focus ) { return; } if ( open_service_inspector_group( '#wpbc_service_form', '[data-service-field="booking_form_id"]' ) ) { state.focus_handled = true; if ( w.history && w.URL ) { var url = new w.URL( w.location.href ); url.searchParams.delete( 'wpbc_service_focus' ); w.history.replaceState( {}, '', url.toString() ); } } } /** Expand or collapse one inspector field group. */ function toggleInspectorGroup( $button ) { var $group = $button.closest( '.wpbc_ui__collapsible_group' ); var $fields = $group.find( '> .group__fields' ); var isOpen = $group.hasClass( 'is-open' ); $group.toggleClass( 'is-open', ! isOpen ); $button.attr( 'aria-expanded', isOpen ? 'false' : 'true' ); $fields.prop( 'hidden', isOpen ).attr( 'aria-hidden', isOpen ? 'true' : 'false' ); } /** Determine whether a Service is currently loaded into the editor. */ function editorIsOpen() { return ! $( '[data-service-field="title"]' ).prop( 'disabled' ); } /** * Synchronize one numeric Service field with its range control. * * Non-price sliders expand to represent an existing value above their normal * visual range. The price slider remains the product-defined 0-1000 control; * its number field can still preserve and submit a legacy value above 1000. * * @param {string} field_id Service field identifier. * @return {void} */ function sync_numeric_range( field_id ) { var $field = $( '[data-service-field="' + field_id + '"]' ); var $range = $( '[data-service-range-field="' + field_id + '"]' ); var value = Number( $field.val() ); var default_min = Number( $range.data( 'service-range-default-min' ) ); var default_max = Number( $range.data( 'service-range-default-max' ) ); var step = Number( $range.attr( 'step' ) || 1 ); var range_max; if ( ! $field.length || ! $range.length || ! isFinite( value ) ) { return; } default_min = isFinite( default_min ) ? default_min : 0; default_max = isFinite( default_max ) ? default_max : 100; step = isFinite( step ) && step > 0 ? step : 1; range_max = 'base_cost' === field_id || value <= default_max ? default_max : default_min + ( Math.ceil( ( value - default_min ) / step ) * step ); $range.attr( { min: default_min, max: range_max } ).val( value ); } /** * Synchronize every numeric Service field with its range control. * * @return {void} */ function sync_all_numeric_ranges() { $( '[data-service-range-field]' ).each( function () { sync_numeric_range( String( $( this ).data( 'service-range-field' ) || '' ) ); } ); } /** * Synchronize the visible Status radios with the stored Service status field. * * @return {void} */ function sync_status_radios() { var status = String( $( '[data-service-field="status"]' ).val() || 'active' ); $( '[data-service-status-choice]' ).each( function () { $( this ).prop( 'checked', String( $( this ).val() ) === status ); } ); } /** * Synchronize the Service image preview with the readonly URL field. * * @return {void} */ function updateMediaPreview() { var pictureUrl = String( $( '[data-service-field="picture_url"]' ).val() || '' ).trim(); var $image = $( '.wpbc_appointment_services__media_image' ); if ( pictureUrl ) { $image.attr( 'src', pictureUrl ); } else { $image.removeAttr( 'src' ); } $image.prop( 'hidden', ! pictureUrl ); $( '.wpbc_appointment_services__media_placeholder' ).prop( 'hidden', !! pictureUrl ); } /** Synchronize toolbar and inspector action states with the page state. */ function updateControls() { var open = state.storageReady && editorIsOpen(); var operation_open = !! state.operation_mode && 'loading' !== state.operation_mode; var operation_is_review = 'bulk_review' === state.operation_mode || 'inline_review' === state.operation_mode || 'delete_review' === state.operation_mode; var operation_is_delete_review = 'delete_review' === state.operation_mode; var hasPicture = !! String( $( '[data-service-field="picture_url"]' ).val() || '' ).trim(); var show_save = ( open || operation_open ) && 'true' === $( '#wpbc_tab_service_settings' ).attr( 'aria-selected' ); $( '.wpbc_appointment_services__add' ).prop( 'hidden', false ).prop( 'disabled', ! state.storageReady || state.busy ); $( '.wpbc_appointment_services__right_sidebar_footer, .wpbc_appointment_services__top_actions' ).prop( 'hidden', ! show_save ); $( '.wpbc_appointment_services__cancel' ).prop( 'hidden', ! show_save ).prop( 'disabled', ( ! open && ! operation_open ) || state.busy ); $( '.wpbc_appointment_services__save' ).prop( 'hidden', ! show_save || operation_open ).prop( 'disabled', ! open || state.busy ); $( '.wpbc_appointment_services__operation_review' ).prop( 'hidden', 'bulk_edit' !== state.operation_mode ).prop( 'disabled', 'bulk_edit' !== state.operation_mode || state.busy || ! collect_bulk_changes() ); $( '.wpbc_appointment_services__operation_apply' ).prop( 'hidden', ! operation_is_review ); if ( ! operation_is_delete_review ) { $( '.wpbc_appointment_services__operation_apply' ).prop( 'disabled', ! operation_is_review || state.busy || ! state.operation_review ); } $( '.wpbc_appointment_services__duplicate, .wpbc_appointment_services__archive' ).prop( 'disabled', ! open || ! state.selectedId || state.busy ); $( '.wpbc_appointment_services__media_preview, .wpbc_appointment_services__select_image' ).prop( 'disabled', ! open || state.busy ); $( '.wpbc_appointment_services__remove_image' ).prop( 'disabled', ! open || ! hasPicture || state.busy ); synchronize_inline_workflow(); } /** Synchronize the shared inline workflow from Service-owned state. */ function synchronize_inline_workflow() { var changed_count = get_inline_changed_count(); if ( ! inlineWorkflowController ) { return; } inlineWorkflowController.synchronize( { active: state.inline_editing, busy: state.busy, changed_count: changed_count, count_text: String( config.i18n.changed_rows || '%s changed rows' ).replace( '%s', changed_count ), has_items: 0 < state.services.length, lock_controls: state.inline_schema_loading || !! state.operation_mode, toggle_disabled: state.catalog_loading || ! state.storageReady || !! state.operation_mode, active_toggle_text: config.i18n.editing_rows, inactive_toggle_text: config.catalog && config.catalog.i18n ? config.catalog.i18n.edit_rows : '' } ); } /** * Stop page-changing catalog controls while a pending operation owns row state. * * Summary elements do not honor the disabled property, so this capture guard * complements the visual disabled state while Service inline or bulk editing is active. * * @param {Event} event Captured catalog event. * @return {void} */ function protect_inline_drafts_from_catalog_controls( event ) { var protected_control; if ( inlineWorkflowController && inlineWorkflowController.protect_event( event, state.inline_editing || state.inline_schema_loading || !! state.operation_mode ) ) { return; } if ( ( ! state.inline_editing && ! state.inline_schema_loading && ! state.operation_mode ) || ! event.target || ! event.target.closest ) { return; } protected_control = event.target.closest( '.wpbc_appointment_services__status_filter, #wpbc_service_provider_filter, .wpbc_appointment_services__add' ); if ( ! protected_control ) { return; } event.preventDefault(); event.stopImmediatePropagation(); } /** Mark the page busy during a mutating request and refresh controls. */ function setBusy( value ) { state.busy = value; $( '.wpbc_appointment_services_page' ).toggleClass( 'is-busy', value ); updateControls(); } /** Enable or disable all fields in the Service inspector. */ function setFieldsEnabled( enabled ) { $( '[data-service-field], [data-service-range-field], [data-service-status-choice]' ).prop( 'disabled', ! enabled ); updateControls(); } /** Return defaults for a new unsaved Service. */ function blankService() { return { service_id: 0, title: '', description: '', picture_url: '', status: 'active', duration_minutes: 30, buffer_before_minutes: 0, buffer_after_minutes: 0, base_cost: '0.00', booking_form_id: 0, resource_ids: ( config.default_provider_ids || [] ).slice() }; } /** * Render the Service-owned create or edit inspector header template. * * Existing Service fields remain in their native collapsible groups so the * template changes presentation without moving domain validation to shared code. * * @param {boolean} is_edit Whether an existing Service is being edited. * @return {void} */ function render_inspector_header( is_edit ) { var template_id = is_edit ? 'wpbc-appointment-service-inspector-edit' : 'wpbc-appointment-service-inspector-create'; var template = catalogTemplate( template_id ); var $header = $( '[data-wpbc-appointment-service-inspector-header]' ); var context = is_edit ? String( config.i18n.inspector_context_id || 'ID: %d' ).replace( '%d', String( state.selectedId ) ) : String( config.i18n.inspector_context_new || 'New' ); if ( ! template || ! $header.length ) { return; } $header.html( template( { title: is_edit ? config.i18n.edit_service_title : config.i18n.create_service_title, context: context, description: is_edit ? config.i18n.edit_service_description : config.i18n.create_service_description } ) ); } /** Populate the inspector from a normalized Service response. */ function fillEditor( service ) { service = $.extend( blankService(), service || {} ); state.selectedId = Number( service.service_id || 0 ); render_inspector_header( 0 < state.selectedId ); $.each( service, function ( key, value ) { $( '[data-service-field="' + key + '"]' ).val( value ); } ); sync_status_radios(); sync_all_numeric_ranges(); updateMediaPreview(); setFieldsEnabled( state.storageReady ); $( '.wpbc_appointment_services__item' ).removeClass( 'is-inspector-selected' ).attr( 'aria-current', 'false' ); $( '.wpbc_appointment_services__item[data-service-id="' + state.selectedId + '"]' ).addClass( 'is-inspector-selected' ).attr( 'aria-current', 'true' ); capture_editor_snapshot(); } /** Collect the current Service inspector values for saving. */ function collectEditor() { var service = { service_id: state.selectedId }; $( '[data-service-field]' ).each( function () { service[ $( this ).data( 'service-field' ) ] = $( this ).val(); } ); return service; } /** * Store the current editor values as the last loaded or saved state. * * @return {void} */ function capture_editor_snapshot() { state.editor_snapshot = JSON.stringify( collectEditor() ); } /** * Determine whether the open Service editor contains unsaved changes. * * @return {boolean} True when current fields differ from the captured state. */ function is_editor_dirty() { return editorIsOpen() && state.editor_snapshot !== JSON.stringify( collectEditor() ); } /** * Confirm before replacing an editor that contains unsaved Service changes. * * @return {boolean} True when replacing the current editor may continue. */ function can_replace_editor() { return ! is_editor_dirty() || w.confirm( config.i18n.confirm_discard || 'Discard unsaved Service changes?' ); } /** * Determine whether the current Service operation contains unapplied values. * * Inline drafts and signed review screens remain dirty until they are * explicitly applied or discarded. This prevents an outside click from * silently replacing a reviewed mutation. * * @return {boolean} True when closing would discard pending changes. */ function is_operation_dirty() { if ( 'bulk_edit' === state.operation_mode ) { return !! collect_bulk_changes(); } if ( 'inline_review' === state.operation_mode || 'bulk_review' === state.operation_mode || 'delete_review' === state.operation_mode ) { return !! state.operation_review; } return state.inline_editing && 0 < get_inline_changed_count(); } /** * Clear the Service editor after its native sidebar has been closed. * * Invalidating the request sequence prevents a late load response from * reopening an inspector that the user already dismissed. * * @return {void} */ function reset_service_editor() { var service = blankService(); state.editor_request_sequence += 1; state.selectedId = 0; state.requested_focus = ''; state.focus_handled = true; render_inspector_header( false ); $.each( service, function ( key, value ) { $( '[data-service-field="' + key + '"]' ).val( value ); } ); sync_status_radios(); sync_all_numeric_ranges(); updateMediaPreview(); setFieldsEnabled( false ); $( '.wpbc_appointment_services__item' ).removeClass( 'is-inspector-selected' ).attr( 'aria-current', 'false' ); capture_editor_snapshot(); updateUrl( 0 ); } /** * Clear a Service inline or bulk operation without changing selection. * * @param {boolean} restore_inline Whether inline rows should return to presentation mode. * @return {void} */ function reset_operation( restore_inline ) { state.operation_request_sequence += 1; state.operation_mode = ''; state.operation_review = null; $( '[data-wpbc-appointment-services-operation-host]' ).empty().prop( 'hidden', true ); $( '[data-wpbc-appointment-services-native-inspector]' ).prop( 'hidden', false ); $( '.wpbc_appointment_services__operation_apply' ) .removeClass( 'wpbc_ui_catalog_delete_review__apply button-secondary is-busy' ) .addClass( 'button-primary' ) .removeAttr( 'aria-busy form' ) .text( config.i18n.apply_changes || 'Apply changes' ); if ( restore_inline ) { state.inline_editing = false; state.inline_drafts = {}; state.inline_schema = {}; state.inline_schema_loading = false; state.inline_request_sequence += 1; $( '[data-wpbc-appointment-services-inline-bar-host]' ).empty(); if ( state.last_response ) { renderCatalogResponse( state.last_response ); } } setBusy( false ); } /** * Close the Service inspector using the Booking Resources lifecycle. * * @param {boolean} confirm_discard Whether to confirm unsaved changes. * @param {boolean} hide_sidebar Whether this function must hide the native sidebar. * @return {boolean} True when the inspector was closed. */ function close_service_inspector( confirm_discard, hide_sidebar ) { var focus_target = state.inspector_focus_target; if ( state.mutation_in_progress ) { return false; } if ( state.operation_mode ) { if ( confirm_discard && is_operation_dirty() && ! w.confirm( config.i18n.confirm_discard || 'Discard unsaved Service changes?' ) ) { return false; } reset_operation( true ); state.inspector_focus_target = null; if ( hide_sidebar && 'function' === typeof w.wpbc_admin_ui__sidebar_right__do_hide ) { w.wpbc_admin_ui__sidebar_right__do_hide(); } notify_setup_wizard_layout_changed(); if ( hide_sidebar && focus_target && document.documentElement.contains( focus_target ) && 'function' === typeof focus_target.focus ) { focus_target.focus(); } return true; } if ( confirm_discard && ! can_replace_editor() ) { return false; } reset_service_editor(); state.inspector_focus_target = null; if ( hide_sidebar && 'function' === typeof w.wpbc_admin_ui__sidebar_right__do_hide ) { w.wpbc_admin_ui__sidebar_right__do_hide(); } notify_setup_wizard_layout_changed(); if ( hide_sidebar && focus_target && document.documentElement.contains( focus_target ) && 'function' === typeof focus_target.focus ) { focus_target.focus(); } return true; } /** Reflect the selected Service in the admin URL without reloading. */ function updateUrl( serviceId ) { if ( ! w.history || ! w.URL ) { return; } var url = new w.URL( w.location.href ); if ( serviceId ) { url.searchParams.set( 'service_id', serviceId ); } else { url.searchParams.delete( 'service_id' ); } w.history.replaceState( {}, '', url.toString() ); } /** Index Provider presentation records by booking resource ID. */ function indexProviders( providers ) { state.providers = {}; $.each( providers || [], function ( index, provider ) { state.providers[ String( provider.id ) ] = provider; } ); } /** Update status and Provider counters above the Service table. */ function updateSummary( counts, providerCount ) { counts = $.extend( { all: 0, active: 0, inactive: 0, archived: 0 }, counts || {} ); state.providerCount = Number( providerCount || 0 ); $.each( counts, function ( status, count ) { $( '[data-service-count="' + status + '"]' ).text( Number( count || 0 ) ); } ); $( '[data-provider-count]' ).text( state.providerCount ); $( '.wpbc_appointment_services__provider_notice' ).prop( 'hidden', 0 !== state.providerCount ); } /** Format a normalized Service cost using the configured currency symbol. */ function formatCost( cost ) { var amount = Number( cost || 0 ); var symbol = config.currency_symbol || '$'; return symbol + amount.toFixed( 2 ); } /** Build compact Provider avatar nodes for one Service row. */ function providerNodes( service ) { var ids = $.map( service.resource_ids || [], function ( value ) { return Number( value || 0 ); } ); var $stack = $( '
', { 'class': 'wpbc_appointment_services__provider_stack' } ); if ( ! ids.length ) { return $( '', { 'class': 'wpbc_appointment_services__no_provider', text: config.i18n.no_provider || 'No Providers assigned' } ); } $.each( ids.slice( 0, 3 ), function ( index, id ) { var provider = state.providers[ String( id ) ] || { id: id, title: 'Provider #' + id, initials: 'P', avatar_url: '' }; var has_availability = false !== provider.has_weekly_availability; var provider_title = provider.title || 'Provider #' + id; var avatar_title = provider_title; var avatar_attributes; var $avatar; if ( ! has_availability ) { avatar_title += ' — ' + ( config.i18n.no_availability || 'No weekly availability' ); } avatar_attributes = { 'class': 'wpbc_appointment_services__provider_avatar' + ( has_availability ? '' : ' has-no-availability' ), title: avatar_title }; if ( provider.availability_url ) { avatar_title = String( config.i18n.edit_availability || 'Edit availability for %s' ).replace( '%s', provider_title ); avatar_attributes.href = provider.availability_url; avatar_attributes.title = avatar_title; avatar_attributes[ 'aria-label' ] = avatar_title; $avatar = $( '', avatar_attributes ); } else { $avatar = $( '', avatar_attributes ); } if ( provider.avatar_url ) { $( '', { src: provider.avatar_url, alt: '', loading: 'lazy' } ).appendTo( $avatar ); } else { $avatar.text( provider.initials || 'P' ); } $stack.append( $avatar ); } ); if ( ids.length > 3 ) { $( '', { 'class': 'wpbc_appointment_services__provider_more', text: '+' + ( ids.length - 3 ), title: ( ids.length - 3 ) + ' ' + ( config.i18n.more_providers || 'more Providers' ) } ).appendTo( $stack ); } return $stack; } /** * Build the Service thumbnail used in the management table. * * @param {Object} service Normalized Service response. * @return {jQuery} Thumbnail wrapper containing an image or placeholder icon. */ function serviceThumbnailNode( service ) { var pictureUrl = String( service.picture_url || '' ).trim(); var service_title = String( service.title || config.i18n.untitled || 'Untitled Service' ); var service_description = String( service.description || '' ).trim() || config.i18n.no_description || 'No description'; var tooltip_format = String( config.i18n.service_thumbnail_tooltip || 'Title: %1$s\nDescription: %2$s' ); var tooltip_text = tooltip_format.replace( '%1$s', service_title ).replace( '%2$s', service_description ); var $thumbnail = $( '', { 'class': 'wpbc_ui_listing__table_icon wpbc_appointment_services__service_thumbnail tooltip_top', 'data-original-title': tooltip_text, 'aria-label': tooltip_text, role: 'img', tabindex: '0' } ); if ( pictureUrl ) { $( '', { src: pictureUrl, alt: '', loading: 'lazy', decoding: 'async' } ).appendTo( $thumbnail ); } else { $( '', { 'class': 'menu_icon icon-1x wpbc-bi-image-fill', 'aria-hidden': 'true' } ).appendTo( $thumbnail ); } return $thumbnail; } /** * Destroy Service thumbnail tooltips before AJAX replaces their elements. * * @return {void} */ function destroy_service_thumbnail_tooltips() { $( '.wpbc_appointment_services__service_thumbnail' ).each( function () { if ( this._tippy && 'function' === typeof this._tippy.destroy ) { this._tippy.destroy(); } } ); } /** * Initialize Service thumbnail tooltips after an AJAX listing render. * * The native title attribute is used only when the Booking Calendar Tippy * helper is unavailable, avoiding duplicate browser and Tippy tooltips. * * @return {void} */ function refresh_service_thumbnail_tooltips() { var listing_selector = '#wpbc_appointment_services_catalog '; var $thumbnails = $( listing_selector + '.wpbc_appointment_services__service_thumbnail' ); var tooltips_initialized = false; if ( 'function' === typeof w.wpbc_define_tippy_tooltips ) { tooltips_initialized = w.wpbc_define_tippy_tooltips( listing_selector ); } if ( tooltips_initialized ) { return; } $thumbnails.each( function () { $( this ).attr( 'title', $( this ).attr( 'data-original-title' ) || '' ); } ); } /** * Build the compact duration and before/after buffer summary for one Service. * * @param {Object} service Normalized Service response. * @return {jQuery} Duration details wrapper for the listing column. */ function service_duration_node( service ) { var duration_minutes = Math.max( 0, Number( service.duration_minutes || 0 ) ); var buffer_before_minutes = Math.max( 0, Number( service.buffer_before_minutes || 0 ) ); var buffer_after_minutes = Math.max( 0, Number( service.buffer_after_minutes || 0 ) ); var duration_format = String( config.i18n.duration_minutes || '%s min' ); var buffers_format = String( config.i18n.buffers_summary || 'Buffers: %1$s / %2$s min' ); var buffers_tooltip_format = String( config.i18n.buffers_tooltip || 'Buffer before: %1$s min; Buffer after: %2$s min' ); var buffers_summary = buffers_format.replace( '%1$s', buffer_before_minutes ).replace( '%2$s', buffer_after_minutes ); var buffers_tooltip = buffers_tooltip_format.replace( '%1$s', buffer_before_minutes ).replace( '%2$s', buffer_after_minutes ); var $duration_details = $( '', { 'class': 'wpbc_appointment_services__duration_details' } ); $( '', { 'class': 'wpbc_appointment_services__duration_value', text: duration_format.replace( '%s', duration_minutes ) } ).appendTo( $duration_details ); $( '', { 'class': 'wpbc_appointment_services__buffers_summary', text: buffers_summary, title: buffers_tooltip, 'aria-label': buffers_tooltip } ).appendTo( $duration_details ); return $duration_details; } /** * Return assigned Providers with recurring availability on one weekday. * * @param {Object} service Normalized Service response. * @param {string} day Weekday key from mon through sun. * @return {Array} Matching Provider presentation records. */ function providers_available_on( service, day ) { var available_providers = []; $.each( service.resource_ids || [], function ( index, id ) { var provider = state.providers[ String( Number( id || 0 ) ) ]; if ( provider && provider.weekdays && provider.weekdays[ day ] ) { available_providers.push( provider ); } } ); return available_providers; } /** * Build compact Provider-specific links below the weekly availability dots. * * @param {Object} service Normalized Service response. * @return {jQuery} Availability links, or an empty collection when unavailable. */ function availability_edit_links( service ) { var $links = $( '
', { 'class': 'wpbc_appointment_services__availability_links', 'aria-label': config.i18n.edit_provider_availability || 'Edit Provider availability' } ); $.each( service.resource_ids || [], function ( index, id ) { var provider = state.providers[ String( Number( id || 0 ) ) ]; var provider_title; var link_title; if ( ! provider || ! provider.availability_url ) { return; } provider_title = provider.title || 'Provider #' + Number( id || 0 ); link_title = String( config.i18n.edit_availability || 'Edit availability for %s' ).replace( '%s', provider_title ); $( '', { 'class': 'wpbc_appointment_services__availability_link', href: provider.availability_url, text: provider.initials || 'P', title: link_title, 'aria-label': link_title } ).appendTo( $links ); } ); return $links.children().length ? $links : $(); } /** Convert a stored Service status to its translated UI label. */ function statusLabel( status ) { if ( 'inactive' === status ) { return config.i18n.draft || 'Draft'; } if ( 'archived' === status ) { return config.i18n.archived || 'Archived'; } return config.i18n.active || 'Active'; } /** Format the translated table pagination summary. */ function showingText( from, to, total ) { var format = config.i18n.showing || 'Showing %1$s-%2$s of %3$s Services'; return format.replace( '%1$s', from ).replace( '%2$s', to ).replace( '%3$s', total ); } /** Return one compiled, allow-listed WordPress template. */ function catalogTemplate( templateId ) { try { return templateId && w.wp && 'function' === typeof w.wp.template ? w.wp.template( templateId ) : null; } catch ( error ) { return null; } } /** * Return the shared native inspector state workflow for Service operations. * * @return {Object|false} Shared inspector workflow or false. */ function get_operation_inspector_workflow() { var template_id; if ( inspectorWorkflowController ) { return inspectorWorkflowController; } if ( ! w.wpbc_ui_catalog || 'function' !== typeof w.wpbc_ui_catalog.create_inspector_workflow ) { return false; } template_id = config.catalog && config.catalog.templates ? config.catalog.templates.inspector : ''; inspectorWorkflowController = w.wpbc_ui_catalog.create_inspector_workflow( { expand: function () { $( '[data-wpbc-appointment-services-operation-host]' ).prop( 'hidden', false ); $( '[data-wpbc-appointment-services-native-inspector]' ).prop( 'hidden', true ); expand_service_inspector(); }, get_footer: function () { return document.querySelector( '.wpbc_appointment_services__right_sidebar_footer' ); }, get_host: function () { return document.querySelector( '[data-wpbc-appointment-services-operation-host]' ); }, render_shell: function ( shell_data ) { var shell_template = catalogTemplate( template_id ); return shell_template ? shell_template( shell_data ) : ''; }, shell_data: { catalog_id: config.catalog && config.catalog.id ? config.catalog.id : 'appointment_services_catalog', empty_icon: 'wpbc-bi-pencil-square', empty_message: '', empty_title: '', loading_label: config.i18n.loading || '' } } ); return inspectorWorkflowController; } /** Return safe HTML from a detached jQuery presentation node. */ function nodeHtml( $node ) { return $node && $node.length ? $( '
' ).append( $node ).html() : ''; } /** * Return the shared selection controller mounted for the Services catalog. * * @return {Object|null} Shared selection controller, or null before mount. */ function get_selection_controller() { var mount = document.getElementById( 'wpbc_appointment_services_catalog' ); return mount && mount._wpbc_ui_catalog_selection_controller ? mount._wpbc_ui_catalog_selection_controller : null; } /** * Return selected Service identifiers without exposing selection internals. * * @return {Array} Persisted selected Service identifiers. */ function get_selected_service_ids() { var selection = get_selection_controller(); return selection && 'function' === typeof selection.get_selected_ids ? selection.get_selected_ids() : []; } /** * Return one Service from the last normalized catalog response. * * @param {number|string} service_id Service identifier to find. * @return {Object|null} Matching Service DTO, or null when not on this page. */ function find_service( service_id ) { var found = null; $.each( state.services, function ( index, service ) { if ( Number( service.service_id || service.id || 0 ) === Number( service_id ) ) { found = service; return false; } } ); return found; } /** * Return the allow-listed initial inline draft for one Service. * * Provider assignments, status, and buffers are deliberately omitted because * they require the reviewed bulk inspector or the complete Service inspector. * * @param {Object} row_schema Server-authoritative row schema. * @return {Object} Editable row-specific draft. */ function create_inline_draft( row_schema ) { var draft = {}; $.each( row_schema && Array.isArray( row_schema.fields ) ? row_schema.fields : [], function ( index, field ) { var field_key = String( field && field.key ? field.key : '' ); if ( field_key ) { draft[ field_key ] = String( field.value ); } } ); return draft; } /** Return one cached server-authoritative inline row schema. */ function find_inline_schema( service_id ) { return state.inline_schema[ String( Number( service_id || 0 ) ) ] || null; } /** * Return whether one inline draft differs from its current Service DTO. * * @param {Object|null} row_schema Current server-authoritative row schema. * @param {Object|null} draft Row-specific inline draft. * @return {boolean} True when at least one allow-listed value changed. */ function inline_draft_changed( row_schema, draft ) { var changed = false; if ( ! row_schema || ! draft ) { return false; } $.each( Array.isArray( row_schema.fields ) ? row_schema.fields : [], function ( index, field ) { if ( String( field.value ) !== String( draft[ field.key ] ) ) { changed = true; return false; } } ); return changed; } /** * Return changed inline drafts keyed by Service ID. * * @return {Object} Changed drafts keyed by Service identifier. */ function collect_inline_changes() { var changes = {}; $.each( state.inline_drafts, function ( service_id, draft ) { var row_schema = find_inline_schema( service_id ); var row_changes = {}; $.each( row_schema && Array.isArray( row_schema.fields ) ? row_schema.fields : [], function ( index, field ) { if ( String( field.value ) !== String( draft[ field.key ] ) ) { row_changes[ field.key ] = draft[ field.key ]; } } ); if ( Object.keys( row_changes ).length ) { changes[ service_id ] = row_changes; } } ); return changes; } /** * Return the count of changed Service rows in inline mode. * * @return {number} Number of row drafts that differ from their DTOs. */ function get_inline_changed_count() { return Object.keys( collect_inline_changes() ).length; } /** * Render and register the sticky inline-editing status bar. * * Registration delegates viewport positioning to the shared selection * controller so the Service page follows the Resource catalog behavior. * * @return {void} */ function render_inline_bar() { var template = catalogTemplate( 'wpbc-appointment-services-inline-bar' ); var changed_count = get_inline_changed_count(); var $host = $( '[data-wpbc-appointment-services-inline-bar-host]' ); if ( ! template || ! $host.length ) { return; } if ( ! $host.children().length ) { $host.html( template( { title: config.i18n.editing_rows, changed_label: String( config.i18n.changed_rows || '%s changed rows' ).replace( '%s', changed_count ), description: config.i18n.inline_help, cancel: config.i18n.cancel, review: config.i18n.review_changes, changed_count: changed_count } ) ); } synchronize_inline_bar(); } /** * Synchronize the inline bar without replacing its active controls. * * A focused inline field emits its final change event while the Review * button is being clicked. Replacing the bar from that change handler would * remove the pointer target before the click event can complete. * * @return {void} */ function synchronize_inline_bar() { var changed_count = get_inline_changed_count(); var $bar = $( '[data-wpbc-appointment-services-inline-bar]' ); if ( ! $bar.length ) { return; } $bar.find( '[data-wpbc-appointment-services-inline-changed-label]' ).text( String( config.i18n.changed_rows || '%s changed rows' ).replace( '%s', changed_count ) ); synchronize_inline_workflow(); } /** * Start row-specific inline editing for the current catalog page. * * Drafts intentionally cover the current page only. Page-changing controls * remain protected until the user cancels or completes the reviewed change. * * @return {void} */ function start_inline_editing() { var request_sequence; var visible_ids; if ( state.busy || state.catalog_loading || state.operation_mode ) { return; } if ( state.inline_editing ) { cancel_inline_editing( true ); return; } if ( ! state.services.length ) { return; } if ( ! can_replace_editor() ) { return; } if ( editorIsOpen() ) { reset_service_editor(); } visible_ids = $.map( state.services, function ( service ) { return Number( service.service_id || service.id || 0 ); } ); request_sequence = ++state.inline_request_sequence; state.inline_editing = false; state.inline_drafts = {}; state.inline_schema = {}; state.inline_schema_loading = true; setBusy( true ); request( config.actions.inline_schema, { ids: JSON.stringify( visible_ids ) } ).done( function ( response ) { var schema = response && response.success && response.data ? response.data.schema : null; if ( request_sequence !== state.inline_request_sequence ) { return; } if ( ! schema || ! Array.isArray( schema.rows ) ) { notify( messageFrom( response, config.i18n.inline_schema_failed ), 'error' ); return; } $.each( schema.rows, function ( index, row_schema ) { var service_id = String( Number( row_schema.service_id || 0 ) ); if ( '0' === service_id || ! Array.isArray( row_schema.fields ) || ! row_schema.fields.length ) { return; } state.inline_schema[ service_id ] = row_schema; state.inline_drafts[ service_id ] = create_inline_draft( row_schema ); } ); if ( ! Object.keys( state.inline_drafts ).length ) { notify( config.i18n.inline_schema_failed, 'error' ); return; } state.inline_editing = true; renderCatalogResponse( state.last_response ); render_inline_bar(); $( '[data-wpbc-appointment-services-inline-field]' ).first().trigger( 'focus' ); } ).fail( function ( xhr ) { if ( request_sequence === state.inline_request_sequence ) { notify( messageFrom( xhr.responseJSON, config.i18n.inline_schema_failed ), 'error' ); } } ).always( function () { if ( request_sequence === state.inline_request_sequence ) { state.inline_schema_loading = false; setBusy( false ); } } ); } /** * Cancel Service inline editing and restore focus to its toolbar action. * * The stable shared data attribute is the interaction contract. Keeping the * cancellation in this domain adapter preserves ownership of Service drafts * while preventing surrounding page and sidebar click handlers from deciding * whether those drafts should be discarded. * * @param {boolean} confirm_discard Whether changed drafts need confirmation. * @return {boolean} True when inline editing was cancelled. */ function cancel_inline_editing( confirm_discard ) { var focus_target = document.querySelector( '[data-wpbc-ui-catalog-inline-toggle]' ); if ( state.mutation_in_progress ) { return false; } if ( confirm_discard && get_inline_changed_count() && ! w.confirm( config.i18n.confirm_discard || 'Discard unsaved Service changes?' ) ) { return false; } reset_operation( true ); if ( focus_target && document.documentElement.contains( focus_target ) && 'function' === typeof focus_target.focus ) { focus_target.focus(); } return true; } /** * Return enabled values from the Service bulk editor. * * @return {Object|null} Shared changes, or null when none enabled. */ function collect_bulk_changes() { var changes = {}; $( '[data-wpbc-appointment-services-bulk-enable]:checked' ).each( function () { var field_id = String( $( this ).data( 'wpbc-appointment-services-bulk-enable' ) || '' ); var $field = $( '[data-wpbc-appointment-services-bulk-value="' + field_id + '"]' ); if ( field_id && $field.length ) { changes[ field_id ] = $field.val(); } } ); return Object.keys( changes ).length ? changes : null; } /** * Open a Service-owned operation inside the native right inspector. * * @param {string} mode Operation state identifier. * @param {string} template_id Allow-listed WordPress template ID. * @param {Object} template_data Presentation data for the template. * @param {HTMLElement} focus_target Element that should regain focus on close. * @return {boolean} True when the operation template was opened. */ function open_operation( mode, template_id, template_data, focus_target ) { var inspector_workflow = get_operation_inspector_workflow(); var template = catalogTemplate( template_id ); var $host = $( '[data-wpbc-appointment-services-operation-host]' ); var rendered_operation; var target; if ( ! template || ! $host.length || ! inspector_workflow || ! inspector_workflow.mount() ) { state.operation_mode = ''; state.operation_review = null; updateControls(); notify( config.i18n.operation_failed || config.i18n.load_failed, 'error' ); return false; } try { rendered_operation = template( template_data ); } catch ( error ) { state.operation_mode = ''; state.operation_review = null; $host.empty().prop( 'hidden', true ); $( '[data-wpbc-appointment-services-native-inspector]' ).prop( 'hidden', false ); updateControls(); notify( config.i18n.operation_failed || config.i18n.load_failed, 'error' ); return false; } state.operation_mode = mode; state.inspector_focus_target = focus_target || document.activeElement; target = inspector_workflow.get_form_target(); if ( ! target ) { state.operation_mode = ''; state.operation_review = null; inspector_workflow.set_state( 'error', config.i18n.operation_failed || config.i18n.load_failed ); updateControls(); notify( config.i18n.operation_failed || config.i18n.load_failed, 'error' ); return false; } target.innerHTML = rendered_operation; inspector_workflow.set_state( 'form', '' ); $host.prop( 'hidden', false ); $( '[data-wpbc-appointment-services-native-inspector]' ).prop( 'hidden', true ); expand_service_inspector(); updateControls(); $host.find( '[data-wpbc-ui-catalog-delete-review-heading], [data-wpbc-ui-catalog-inline-review-heading], h2' ).first().attr( 'tabindex', '-1' ).trigger( 'focus' ); return true; } /** * Open bulk editing for the current persistent Service selection. * * @param {HTMLElement} focus_target Element that opened the bulk inspector. * @return {void} */ function open_bulk_edit( focus_target ) { var selected_ids = get_selected_service_ids(); if ( ! selected_ids.length || state.busy || state.inline_editing ) { return; } if ( ! can_replace_editor() ) { return; } if ( editorIsOpen() ) { reset_service_editor(); } setBusy( true ); request( config.actions.bulk_contract, { ids: JSON.stringify( selected_ids ) } ).done( function ( response ) { var contract = response && response.success && response.data ? response.data.contract : null; if ( ! contract || ! Array.isArray( contract.fields ) || ! contract.fields.length ) { notify( messageFrom( response, config.i18n.bulk_contract_failed ), 'error' ); return; } open_operation( 'bulk_edit', 'wpbc-appointment-services-bulk-edit', { title: config.i18n.bulk_edit_title, description: contract.message || config.i18n.bulk_edit_description, fields: contract.fields }, focus_target ); } ).fail( function ( xhr ) { notify( messageFrom( xhr.responseJSON, config.i18n.bulk_contract_failed ), 'error' ); } ).always( function () { setBusy( false ); } ); } /** * Render a signed inline or bulk review in the Service inspector. * * The preview endpoint is non-mutating. Only the returned signed plan can be * submitted to the separate apply endpoint. * * @param {string} mode Either inline or bulk. * @param {Array} ids Service identifiers to review. * @param {Object} changes Row-specific or shared field changes. * @param {HTMLElement} focus_target Element that opened the review. * @return {void} */ function preview_operation( mode, ids, changes, focus_target ) { var inspector_workflow; var request_sequence; if ( state.busy || ! ids.length || ! changes ) { return; } inspector_workflow = get_operation_inspector_workflow(); request_sequence = ++state.operation_request_sequence; state.operation_mode = 'loading'; state.operation_review = null; state.inspector_focus_target = focus_target || document.activeElement; if ( ! inspector_workflow || ! inspector_workflow.open_loading() ) { reset_operation( false ); notify( config.i18n.operation_failed || config.i18n.preview_failed, 'error' ); return; } setBusy( true ); request( config.actions.preview, { mode: mode, ids: JSON.stringify( ids ), changes: JSON.stringify( changes ) }, false ).done( function ( response ) { var review = response && response.success ? response.data : null; var review_workflow = get_inline_review_workflow(); var review_rows; var review_model; var template_id = 'inline' === mode ? 'wpbc-appointment-services-inline-review' : 'wpbc-appointment-services-bulk-review'; if ( request_sequence !== state.operation_request_sequence ) { return; } if ( ! review || ! review.plan || ! review.token ) { var error_message = messageFrom( response, config.i18n.preview_failed ); inspector_workflow.set_state( 'error', error_message ); notify( error_message, 'error' ); return; } state.operation_review = review; review_rows = review.review && Array.isArray( review.review.rows ) ? review.review.rows : []; review_model = review_workflow ? review_workflow.prepare( review.review || {}, { changed_label: String( config.i18n.changed_rows || '%s changed rows' ).replace( '%s', String( review_rows.length ) ), description: config.i18n.review_confirmation || '', form_id: 'wpbc_appointment_services_' + mode + '_review_form', mode: mode + '_review', pending_message: config.i18n.review_description || '', title: 'inline' === mode ? config.i18n.inline_review_title : config.i18n.bulk_review_title } ) : {}; if ( ! open_operation( mode + '_review', template_id, review_model, focus_target ) ) { inspector_workflow.set_state( 'error', config.i18n.operation_failed || config.i18n.preview_failed ); return; } if ( review_workflow ) { review_workflow.synchronize( { busy: false, can_apply: true } ); } } ).fail( function ( xhr ) { var error_message = messageFrom( xhr.responseJSON, config.i18n.preview_failed ); if ( request_sequence !== state.operation_request_sequence ) { return; } state.operation_mode = 'loading'; inspector_workflow.set_state( 'error', error_message ); notify( error_message, 'error' ); } ).always( function () { if ( request_sequence === state.operation_request_sequence ) { setBusy( false ); } } ); } /** * Open a loading inspector immediately and request a signed deletion review. * * @param {Array} ids Selected Service identifiers. * @param {HTMLElement} focus_target Control that opened the review. * @return {void} */ function preview_deletion( ids, focus_target ) { var inspector_workflow; var request_sequence; ids = Array.isArray( ids ) ? ids.map( Number ).filter( function ( service_id ) { return service_id > 0; } ) : []; if ( state.busy || ! ids.length || state.inline_editing ) { return; } if ( ! can_replace_editor() ) { return; } if ( editorIsOpen() ) { reset_service_editor(); } inspector_workflow = get_operation_inspector_workflow(); request_sequence = ++state.operation_request_sequence; state.operation_mode = 'loading'; state.operation_review = null; state.inspector_focus_target = focus_target || document.activeElement; if ( ! inspector_workflow || ! inspector_workflow.open_loading() ) { reset_operation( false ); notify( config.i18n.operation_failed || config.i18n.delete_preview_failed, 'error' ); return; } setBusy( true ); request( config.actions.delete_preview, { ids: JSON.stringify( ids ) }, false ).done( function ( response ) { var review = response && response.success ? response.data : null; var delete_review = review && review.delete_review ? review.delete_review : {}; var delete_i18n = delete_review.i18n || {}; var delete_workflow = get_delete_review_workflow(); var review_model; if ( request_sequence !== state.operation_request_sequence ) { return; } if ( ! review || ! review.plan || ! review.token ) { inspector_workflow.set_state( 'error', messageFrom( response, config.i18n.delete_preview_failed ) ); return; } review_model = { acknowledgement: String( delete_i18n.acknowledgement || '' ), actions_heading: String( delete_i18n.actions_heading || '' ), can_apply: true === review.can_apply, description: String( delete_i18n.description || '' ), id_label: String( delete_i18n.id_label || config.i18n.column_id || 'ID' ), items: Array.isArray( delete_review.items ) ? delete_review.items : [], items_heading: String( delete_i18n.items_heading || '' ), pending_message: String( delete_i18n.pending_message || '' ), selection_label: String( delete_i18n.selection_label || '' ), title: String( delete_i18n.title || '' ), warning: String( delete_review.warning || review.warning || '' ) }; state.operation_review = review; if ( ! open_operation( 'delete_review', 'wpbc-appointment-services-delete-review', review_model, focus_target ) ) { inspector_workflow.set_state( 'error', config.i18n.delete_preview_failed ); return; } if ( delete_workflow ) { delete_workflow.configure_footer( { can_apply: true === review.can_apply, footer: document.querySelector( '.wpbc_appointment_services__right_sidebar_footer' ), form_id: 'wpbc_appointment_services_delete_review_form', label: String( delete_i18n.delete_button || '' ) } ); delete_workflow.synchronize( { busy: false, can_apply: true === review.can_apply } ); if ( true === review.can_apply ) { delete_workflow.pulse_acknowledgement(); } } } ).fail( function ( xhr ) { var error_message = messageFrom( xhr.responseJSON, config.i18n.delete_preview_failed ); if ( request_sequence !== state.operation_request_sequence ) { return; } state.operation_mode = 'loading'; inspector_workflow.set_state( 'error', error_message ); notify( error_message, 'error' ); } ).always( function () { if ( request_sequence === state.operation_request_sequence ) { setBusy( false ); } } ); } /** * Apply the current signed Service review, then refresh the active page. * * Selection remains owned by the shared controller and is therefore restored * after the AJAX refresh instead of being silently cleared by the mutation. * * @return {void} */ function apply_operation() { var review = state.operation_review; var changed_ids; var is_delete = 'delete_review' === state.operation_mode; var acknowledgement; if ( state.busy || ! review || ! review.plan || ! review.token ) { return; } if ( is_delete ) { acknowledgement = document.querySelector( '[data-wpbc-ui-catalog-delete-acknowledgement]' ); if ( true !== review.can_apply || ! acknowledgement || ! acknowledgement.checked ) { if ( get_delete_review_workflow() ) { get_delete_review_workflow().pulse_acknowledgement(); } return; } } state.mutation_in_progress = true; setBusy( true ); if ( is_delete && get_delete_review_workflow() ) { get_delete_review_workflow().synchronize( { busy: true, can_apply: true } ); } else if ( get_inline_review_workflow() ) { get_inline_review_workflow().synchronize( { busy: true, can_apply: true } ); } request( is_delete ? config.actions.delete_apply : config.actions.apply, { acknowledged: is_delete ? '1' : '', plan: JSON.stringify( review.plan ), token: review.token } ).done( function ( response ) { if ( ! response || ! response.success ) { notify( messageFrom( response, is_delete ? config.i18n.delete_apply_failed : config.i18n.apply_failed ), 'error' ); return; } changed_ids = response.data && Array.isArray( response.data.changed_ids ) ? response.data.changed_ids.map( String ) : []; notify( response.data.message, 'success' ); state.mutation_in_progress = false; close_service_inspector( false, true ); pending_highlight_ids = is_delete ? [] : changed_ids; if ( is_delete && catalogController && 'function' === typeof catalogController.clear_selection ) { catalogController.clear_selection(); } if ( catalogController ) { catalogController.load( { page_number: state.page } ); } } ).fail( function ( xhr ) { notify( messageFrom( xhr.responseJSON, is_delete ? config.i18n.delete_apply_failed : config.i18n.apply_failed ), 'error' ); } ).always( function () { state.mutation_in_progress = false; setBusy( false ); if ( is_delete && get_delete_review_workflow() ) { get_delete_review_workflow().synchronize( { busy: false, can_apply: !! state.operation_review && true === state.operation_review.can_apply } ); } else if ( get_inline_review_workflow() ) { get_inline_review_workflow().synchronize( { busy: false, can_apply: !! state.operation_review } ); } } ); } /** * Highlight Services changed by the last reviewed mutation. * * The identifiers are retained until the refreshed catalog contains at least * one affected Service. This avoids consuming the highlight while closing an * operation re-renders the previous response. * * @return {void} */ function apply_pending_highlights() { var first_service = null; pending_highlight_ids.forEach( function ( service_id ) { var service = document.querySelector( '.wpbc_appointment_services__item[data-service-id="' + service_id + '"]' ); if ( service ) { service.classList.add( 'is-recently-saved' ); first_service = first_service || service; } } ); if ( ! first_service ) { return; } first_service.scrollIntoView( { block: 'nearest', behavior: 'smooth' } ); window.setTimeout( function () { document.querySelectorAll( '.wpbc_appointment_services__item.is-recently-saved' ).forEach( function ( service ) { service.classList.remove( 'is-recently-saved' ); } ); }, 5000 ); pending_highlight_ids = []; } /** Return the current ordered and visible Service column definitions. */ function responseColumns( response ) { var definitions = config.catalog && config.catalog.columns ? config.catalog.columns.definitions || {} : {}; var visible = response.display && response.display.visible_columns ? response.display.visible_columns : []; var order = response.display && response.display.column_order ? response.display.column_order : []; return $.map( order, function ( columnId ) { var definition = definitions[ columnId ]; var is_sorted; if ( ! definition || -1 === visible.indexOf( columnId ) ) { return null; } is_sorted = !! definition.sort_key && definition.sort_key === response.sorting.sort_by; return { aria_sort: is_sorted ? ( 'desc' === response.sorting.sort_order ? 'descending' : 'ascending' ) : 'none', id: columnId, label: definition.label || columnId, class_name: definition.class || 'column-' + columnId, is_sorted: is_sorted, sort_icon: is_sorted ? ( 'desc' === response.sorting.sort_order ? 'wpbc-bi-arrow-down' : 'wpbc-bi-arrow-up' ) : 'wpbc_icn_import_export', sort_key: definition.sort_key || '' }; } ); } /** * Return sortable presentation records for the cards layout header. * * The table combines Status and Service ID in one column, so cards expand * that column into two independent sort controls without changing the DTO. * * @param {Object} response Normalized shared catalog response. * @return {Array} Visible, allow-listed cards sorting records. */ function responseSortColumns( response ) { var columns = []; $.each( responseColumns( response ), function ( columnIndex, column ) { if ( 'status' === column.id ) { columns.push( { is_sorted: 'status' === response.sorting.sort_by, label: column.label, sort_icon: 'status' === response.sorting.sort_by ? ( 'desc' === response.sorting.sort_order ? 'wpbc-bi-arrow-down' : 'wpbc-bi-arrow-up' ) : 'wpbc_icn_import_export', sort_key: 'status' } ); columns.push( { is_sorted: 'service_id' === response.sorting.sort_by, label: config.i18n.column_id || 'ID', sort_icon: 'service_id' === response.sorting.sort_by ? ( 'desc' === response.sorting.sort_order ? 'wpbc-bi-arrow-down' : 'wpbc-bi-arrow-up' ) : 'wpbc_icn_import_export', sort_key: 'service_id' } ); return; } if ( ! column.sort_key ) { return; } columns.push( { is_sorted: column.sort_key === response.sorting.sort_by, label: column.label, sort_icon: column.sort_key === response.sorting.sort_by ? ( 'desc' === response.sorting.sort_order ? 'wpbc-bi-arrow-down' : 'wpbc-bi-arrow-up' ) : 'wpbc_icn_import_export', sort_key: column.sort_key } ); } ); return columns; } /** * Build presentation cells for the Service-owned row and card templates. * * Inline drafts replace only the allow-listed editable controls. Provider, * availability, status, and action presentation stays read-only. * * @param {Object} service Normalized Service DTO. * @return {Object} Escaped HTML fragments keyed by catalog column ID. */ function serviceCells( service ) { var id = Number( service.service_id || service.id || 0 ); var draft = state.inline_editing ? state.inline_drafts[ String( id ) ] : null; var row_schema = draft ? find_inline_schema( id ) : null; var inline_field_template = catalogTemplate( 'wpbc-appointment-service-inline-field' ); var inline_cells = {}; var title = service.title || config.i18n.untitled || 'Untitled Service'; var description = String( service.description || '#' + id ); var status = String( service.status || 'active' ); var $identity = $( '
', { 'class': 'wpbc_appointment_services__service_identity' } ); var identity_fields_class = draft ? 'wpbc_appointment_services__inline_identity_fields' : 'wpbc_ui_listing__item_copy wpbc_appointment_services__service_copy'; var $copy = $( '', { 'class': identity_fields_class } ).appendTo( $identity ); var $availability = $( '
' ); var $availabilityWeek = $( '
', { 'class': 'wpbc_appointment_services__availability_week' } ).appendTo( $availability ); var hasWeeklyAvailability = false; var statusTemplate = catalogTemplate( 'wpbc-appointment-service-status-label' ); var providerTemplate = catalogTemplate( 'wpbc-appointment-service-provider-labels' ); var $actions = $( '
', { 'class': 'wpbc_appointment_services__actions' } ); if ( row_schema && inline_field_template ) { $.each( row_schema.fields || [], function ( field_index, field ) { var column_id = String( field.column || '' ); var field_key = String( field.key || '' ); var field_data; if ( ! column_id || ! field_key || ! Object.prototype.hasOwnProperty.call( draft, field_key ) ) { return; } field_data = $.extend( {}, field, { original_value: field.value, value: draft[ field_key ] } ); inline_cells[ column_id ] = ( inline_cells[ column_id ] || '' ) + inline_field_template( { field: field_data, service_id: id } ); } ); } serviceThumbnailNode( service ).prependTo( $identity ); if ( draft && inline_cells.service ) { $copy.append( inline_cells.service ); } else { $( '', { 'class': 'wpbc_ui_listing__item_title wpbc_ui_listing__overflow_tooltip', 'data-wpbc-ui-catalog-overflow-tooltip': title, title: title, text: title } ).appendTo( $copy ); $( '', { 'class': 'wpbc_ui_listing__item_description wpbc_ui_listing__overflow_tooltip', 'data-wpbc-ui-catalog-overflow-tooltip': description, title: description, text: description } ).appendTo( $copy ); } $.each( weekdayKeys, function ( dayIndex, day ) { var availableProviders = providers_available_on( service, day ); var available = availableProviders.length > 0; var dayTitle = config.weekdays && config.weekdays[ dayIndex ] ? config.weekdays[ dayIndex ] : day; var providerTitles = $.map( availableProviders, function ( provider ) { return provider.title || ''; } ).filter( function ( providerTitle ) { return !! providerTitle; } ); var availabilityTitle = available ? String( config.i18n.available_providers || 'Available Providers: %s' ).replace( '%s', providerTitles.join( ', ' ) ) : ( config.i18n.no_available_providers || 'No assigned Providers are available' ); if ( available ) { hasWeeklyAvailability = true; } $( '', { 'class': 'wpbc_appointment_services__availability' + ( available ? ' is-available' : '' ), title: dayTitle + ': ' + availabilityTitle, 'aria-label': dayTitle + ': ' + availabilityTitle } ).appendTo( $availabilityWeek ); } ); $availability.append( availability_edit_links( service ) ); if ( service.resource_ids && service.resource_ids.length && ! hasWeeklyAvailability ) { $( '', { 'class': 'wpbc_appointment_services__availability_empty', text: config.i18n.no_availability || 'No weekly availability' } ).appendTo( $availability ); } $( '