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-appointment-services / _src / appointment_services_page.js

appointment_services_page.js in Booking Calendar 11.9, at includes/page-appointment-services/_src/appointment_services_page.js

1,848 lines 86.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 ( function ( w, $ ) {
2 'use strict';
3 var config = w.wpbc_appointment_services_config || {};
4 var state = {
5 storageReady: false,
6 catalog_loading: true,
7 selectedId: Number( config.selected_id || 0 ),
8 requested_focus: String( config.focus_section || '' ),
9 focus_handled: false,
10 busy: false,
11 status: 'all',
12 services: [],
13 providers: {},
14 providerCount: 0,
15 editor_snapshot: '',
16 editor_request_sequence: 0,
17 initial_selection_pending: 0 < Number( config.selected_id || 0 ),
18 inspector_focus_target: null,
19 mutation_in_progress: false,
20 operation_mode: '',
21 operation_review: null,
22 operation_request_sequence: 0,
23 inline_editing: false,
24 inline_drafts: {},
25 inline_schema: {},
26 inline_schema_loading: false,
27 inline_request_sequence: 0,
28 last_response: null,
29 page: 1,
30 page_size: Number( config.catalog && config.catalog.initial_request && config.catalog.initial_request.items_per_page ? config.catalog.initial_request.items_per_page : 10 ),
31 total_items: 0,
32 total_pages: 0,
33 sort_by: String( config.catalog && config.catalog.initial_request && config.catalog.initial_request.sort_by ? config.catalog.initial_request.sort_by : 'service_id' ),
34 sort_order: String( config.catalog && config.catalog.initial_request && config.catalog.initial_request.sort_order ? config.catalog.initial_request.sort_order : 'desc' )
35 };
36 var catalogController = false;
37 var inlineWorkflowController = false;
38 var inlineReviewWorkflowController = false;
39 var deleteReviewWorkflowController = false;
40 var inspectorWorkflowController = false;
41 var searchTimer = 0;
42 var weekdayKeys = [ 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' ];
43 var pending_highlight_ids = [];
44
45 /** Extract an API message while preserving a caller-provided fallback. */
46 function messageFrom( response, fallback ) { return response && response.data && response.data.message ? response.data.message : fallback; }
47 /** Display a shared Booking Calendar administrator notice. */
48 function notify( message, type ) {
49 if ( message && typeof w.wpbc_admin_show_message === 'function' ) { w.wpbc_admin_show_message( message, type || 'info', 5000, false ); }
50 }
51 /**
52 * Return the shared signed-review presentation controller.
53 *
54 * @return {Object|false} Shared review controller or false when unavailable.
55 */
56 function get_inline_review_workflow() {
57 if ( inlineReviewWorkflowController ) { return inlineReviewWorkflowController; }
58 if ( ! w.wpbc_ui_catalog || 'function' !== typeof w.wpbc_ui_catalog.create_inline_review_workflow ) { return false; }
59 inlineReviewWorkflowController = w.wpbc_ui_catalog.create_inline_review_workflow( {
60 apply_selector: '.wpbc_appointment_services__operation_apply',
61 cancel_selector: '.wpbc_appointment_services__cancel',
62 root: document
63 } );
64 return inlineReviewWorkflowController;
65 }
66 /**
67 * Return the shared permanent-deletion presentation controller.
68 *
69 * @return {Object|false} Shared deletion controller or false when unavailable.
70 */
71 function get_delete_review_workflow() {
72 if ( deleteReviewWorkflowController ) { return deleteReviewWorkflowController; }
73 if ( ! w.wpbc_ui_catalog || 'function' !== typeof w.wpbc_ui_catalog.create_delete_review_workflow ) { return false; }
74 deleteReviewWorkflowController = w.wpbc_ui_catalog.create_delete_review_workflow( {
75 acknowledgement_selector: '[data-wpbc-ui-catalog-delete-acknowledgement]',
76 apply_selector: '.wpbc_appointment_services__operation_apply',
77 cancel_selector: '.wpbc_appointment_services__cancel',
78 root: document
79 } );
80 return deleteReviewWorkflowController;
81 }
82 /**
83 * Show the native Booking Calendar Processing notice.
84 *
85 * The returned element identifies this specific request's notice so
86 * overlapping requests cannot dismiss each other's feedback.
87 *
88 * @return {jQuery} Processing notice wrapper, or an empty collection.
89 */
90 function show_processing_notice() {
91 if ( 'function' !== typeof w.wpbc_admin_show_message_processing ) {
92 return $();
93 }
94
95 w.wpbc_admin_show_message_processing( '' );
96
97 return $( '#ajax_working .wpbc_processing.wpbc_spin' ).last().closest( '[id^="wpbc_notice_"]' );
98 }
99 /**
100 * Hide the Processing notice created for one completed request.
101 *
102 * @param {jQuery} $processing_notice Notice wrapper returned by show_processing_notice().
103 * @return {void}
104 */
105 function hide_processing_notice( $processing_notice ) {
106 if ( $processing_notice && $processing_notice.length ) {
107 $processing_notice.stop( true, true ).hide();
108 }
109 }
110 /**
111 * Send an authenticated Appointment Services AJAX request.
112 *
113 * Requests normally use the shared administrator Processing notice and remove
114 * only their own notice after settling. Inspector-loading requests may opt out
115 * because the shared inspector already exposes equivalent progress feedback.
116 *
117 * @param {string} action WordPress AJAX action name.
118 * @param {Object} data Request-specific payload.
119 * @param {boolean} use_processing_notice Whether to show the global notice.
120 * @return {jqXHR} jQuery AJAX promise for the request.
121 */
122 function request( action, data, use_processing_notice ) {
123 var $processing_notice = false === use_processing_notice ? $() : show_processing_notice();
124
125 return $.ajax( { url: config.ajax_url, type: 'POST', dataType: 'json', data: $.extend( { action: action, nonce: config.nonce }, data || {} ) } )
126 .always( function () { hide_processing_notice( $processing_notice ); } );
127 }
128 /** Activate the Settings or Help panel selected in the right sidebar. */
129 function switchRightPanel( $tab ) {
130 var panelId = $tab.attr( 'aria-controls' );
131 var panel = panelId ? document.getElementById( panelId ) : null;
132 var $tabs = $tab.closest( '.wpbc_appointment_services__rightbar_tabs' ).find( '[role="tab"]' );
133 var $panels = $( '.wpbc_appointment_services__rightbar [role="tabpanel"]' );
134 if ( ! panel ) { return; }
135 $tabs.attr( 'aria-selected', 'false' );
136 $tab.attr( 'aria-selected', 'true' );
137 $panels.prop( 'hidden', true ).attr( 'aria-hidden', 'true' );
138 $( panel ).prop( 'hidden', false ).attr( 'aria-hidden', 'false' );
139 updateControls();
140 }
141 /**
142 * Tell the active Setup Wizard bar that the page workspace changed width.
143 *
144 * The delayed notification runs after the shared sidebar transition so the
145 * setup bar can measure the final inspector boundary.
146 *
147 * @return {void}
148 */
149 function notify_setup_wizard_layout_changed() {
150 $( document ).trigger( 'wpbc_setup_wizard_layout_changed' );
151 w.setTimeout( function () { $( document ).trigger( 'wpbc_setup_wizard_layout_changed' ); }, 300 );
152 }
153 /**
154 * Expand the right sidebar and display the Service Settings inspector.
155 *
156 * @return {void}
157 */
158 function expand_service_inspector() {
159 var $settings_tab = $( '#wpbc_tab_service_settings' );
160
161 if ( 'function' === typeof w.wpbc_admin_ui__sidebar_right__do_max ) {
162 w.wpbc_admin_ui__sidebar_right__do_max();
163 }
164 if ( $settings_tab.length ) {
165 switchRightPanel( $settings_tab );
166 }
167 notify_setup_wizard_layout_changed();
168 }
169 /**
170 * Reveal, expand, highlight, and optionally focus one Service editor group.
171 *
172 * @param {string} fields_selector Group fields selector from the fixed editor markup.
173 * @param {string} focus_selector Optional control selector to focus after scrolling.
174 * @return {boolean} True when the requested inspector group was found.
175 */
176 function open_service_inspector_group( fields_selector, focus_selector ) {
177 var $group_fields = $( fields_selector );
178 var $group = $group_fields.closest( '.wpbc_ui__collapsible_group' );
179 var $group_header = $group.children( '.group__header' );
180 var group_element = $group.get( 0 );
181 var focus_element = focus_selector ? document.querySelector( focus_selector ) : null;
182
183 expand_service_inspector();
184 if ( ! $group.length ) {
185 return false;
186 }
187
188 $group.addClass( 'is-open' );
189 $group_header.attr( 'aria-expanded', 'true' );
190 $group_fields.prop( 'hidden', false ).attr( 'aria-hidden', 'false' );
191 $group.removeClass( 'wpbc_appointment_services__focus_pulse' );
192 if ( group_element ) {
193 void group_element.offsetWidth;
194 $group.addClass( 'wpbc_appointment_services__focus_pulse' );
195 try { group_element.scrollIntoView( { behavior: 'smooth', block: 'start', inline: 'nearest' } ); }
196 catch ( error ) { group_element.scrollIntoView( true ); }
197 w.setTimeout( function () { $group.removeClass( 'wpbc_appointment_services__focus_pulse' ); }, 900 );
198 }
199 if ( focus_element && typeof focus_element.focus === 'function' ) {
200 try { focus_element.focus( { preventScroll: true } ); }
201 catch ( error ) { focus_element.focus(); }
202 }
203
204 return true;
205 }
206 /**
207 * Open, reveal, and highlight the General Service editor section.
208 *
209 * @return {void}
210 */
211 function open_add_service_inspector() {
212 open_service_inspector_group( '#wpbc_service_general', '[data-service-field="title"]' );
213 }
214 /**
215 * Apply the one-time inspector focus requested by an administration link.
216 *
217 * @return {void}
218 */
219 function focus_requested_service_section() {
220 if ( state.focus_handled || 'booking_form' !== state.requested_focus ) {
221 return;
222 }
223
224 if ( open_service_inspector_group( '#wpbc_service_form', '[data-service-field="booking_form_id"]' ) ) {
225 state.focus_handled = true;
226 if ( w.history && w.URL ) {
227 var url = new w.URL( w.location.href );
228 url.searchParams.delete( 'wpbc_service_focus' );
229 w.history.replaceState( {}, '', url.toString() );
230 }
231 }
232 }
233 /** Expand or collapse one inspector field group. */
234 function toggleInspectorGroup( $button ) {
235 var $group = $button.closest( '.wpbc_ui__collapsible_group' );
236 var $fields = $group.find( '> .group__fields' );
237 var isOpen = $group.hasClass( 'is-open' );
238 $group.toggleClass( 'is-open', ! isOpen );
239 $button.attr( 'aria-expanded', isOpen ? 'false' : 'true' );
240 $fields.prop( 'hidden', isOpen ).attr( 'aria-hidden', isOpen ? 'true' : 'false' );
241 }
242 /** Determine whether a Service is currently loaded into the editor. */
243 function editorIsOpen() { return ! $( '[data-service-field="title"]' ).prop( 'disabled' ); }
244 /**
245 * Synchronize one numeric Service field with its range control.
246 *
247 * Non-price sliders expand to represent an existing value above their normal
248 * visual range. The price slider remains the product-defined 0-1000 control;
249 * its number field can still preserve and submit a legacy value above 1000.
250 *
251 * @param {string} field_id Service field identifier.
252 * @return {void}
253 */
254 function sync_numeric_range( field_id ) {
255 var $field = $( '[data-service-field="' + field_id + '"]' );
256 var $range = $( '[data-service-range-field="' + field_id + '"]' );
257 var value = Number( $field.val() );
258 var default_min = Number( $range.data( 'service-range-default-min' ) );
259 var default_max = Number( $range.data( 'service-range-default-max' ) );
260 var step = Number( $range.attr( 'step' ) || 1 );
261 var range_max;
262
263 if ( ! $field.length || ! $range.length || ! isFinite( value ) ) { return; }
264 default_min = isFinite( default_min ) ? default_min : 0;
265 default_max = isFinite( default_max ) ? default_max : 100;
266 step = isFinite( step ) && step > 0 ? step : 1;
267 range_max = 'base_cost' === field_id || value <= default_max
268 ? default_max
269 : default_min + ( Math.ceil( ( value - default_min ) / step ) * step );
270 $range.attr( { min: default_min, max: range_max } ).val( value );
271 }
272 /**
273 * Synchronize every numeric Service field with its range control.
274 *
275 * @return {void}
276 */
277 function sync_all_numeric_ranges() {
278 $( '[data-service-range-field]' ).each( function () { sync_numeric_range( String( $( this ).data( 'service-range-field' ) || '' ) ); } );
279 }
280 /**
281 * Synchronize the visible Status radios with the stored Service status field.
282 *
283 * @return {void}
284 */
285 function sync_status_radios() {
286 var status = String( $( '[data-service-field="status"]' ).val() || 'active' );
287 $( '[data-service-status-choice]' ).each( function () {
288 $( this ).prop( 'checked', String( $( this ).val() ) === status );
289 } );
290 }
291 /**
292 * Synchronize the Service image preview with the readonly URL field.
293 *
294 * @return {void}
295 */
296 function updateMediaPreview() {
297 var pictureUrl = String( $( '[data-service-field="picture_url"]' ).val() || '' ).trim();
298 var $image = $( '.wpbc_appointment_services__media_image' );
299 if ( pictureUrl ) { $image.attr( 'src', pictureUrl ); } else { $image.removeAttr( 'src' ); }
300 $image.prop( 'hidden', ! pictureUrl );
301 $( '.wpbc_appointment_services__media_placeholder' ).prop( 'hidden', !! pictureUrl );
302 }
303 /** Synchronize toolbar and inspector action states with the page state. */
304 function updateControls() {
305 var open = state.storageReady && editorIsOpen();
306 var operation_open = !! state.operation_mode && 'loading' !== state.operation_mode;
307 var operation_is_review = 'bulk_review' === state.operation_mode || 'inline_review' === state.operation_mode || 'delete_review' === state.operation_mode;
308 var operation_is_delete_review = 'delete_review' === state.operation_mode;
309 var hasPicture = !! String( $( '[data-service-field="picture_url"]' ).val() || '' ).trim();
310 var show_save = ( open || operation_open ) && 'true' === $( '#wpbc_tab_service_settings' ).attr( 'aria-selected' );
311 $( '.wpbc_appointment_services__add' ).prop( 'hidden', false ).prop( 'disabled', ! state.storageReady || state.busy );
312 $( '.wpbc_appointment_services__right_sidebar_footer, .wpbc_appointment_services__top_actions' ).prop( 'hidden', ! show_save );
313 $( '.wpbc_appointment_services__cancel' ).prop( 'hidden', ! show_save ).prop( 'disabled', ( ! open && ! operation_open ) || state.busy );
314 $( '.wpbc_appointment_services__save' ).prop( 'hidden', ! show_save || operation_open ).prop( 'disabled', ! open || state.busy );
315 $( '.wpbc_appointment_services__operation_review' ).prop( 'hidden', 'bulk_edit' !== state.operation_mode ).prop( 'disabled', 'bulk_edit' !== state.operation_mode || state.busy || ! collect_bulk_changes() );
316 $( '.wpbc_appointment_services__operation_apply' ).prop( 'hidden', ! operation_is_review );
317 if ( ! operation_is_delete_review ) {
318 $( '.wpbc_appointment_services__operation_apply' ).prop( 'disabled', ! operation_is_review || state.busy || ! state.operation_review );
319 }
320 $( '.wpbc_appointment_services__duplicate, .wpbc_appointment_services__archive' ).prop( 'disabled', ! open || ! state.selectedId || state.busy );
321 $( '.wpbc_appointment_services__media_preview, .wpbc_appointment_services__select_image' ).prop( 'disabled', ! open || state.busy );
322 $( '.wpbc_appointment_services__remove_image' ).prop( 'disabled', ! open || ! hasPicture || state.busy );
323 synchronize_inline_workflow();
324 }
325
326 /** Synchronize the shared inline workflow from Service-owned state. */
327 function synchronize_inline_workflow() {
328 var changed_count = get_inline_changed_count();
329
330 if ( ! inlineWorkflowController ) { return; }
331 inlineWorkflowController.synchronize( {
332 active: state.inline_editing,
333 busy: state.busy,
334 changed_count: changed_count,
335 count_text: String( config.i18n.changed_rows || '%s changed rows' ).replace( '%s', changed_count ),
336 has_items: 0 < state.services.length,
337 lock_controls: state.inline_schema_loading || !! state.operation_mode,
338 toggle_disabled: state.catalog_loading || ! state.storageReady || !! state.operation_mode,
339 active_toggle_text: config.i18n.editing_rows,
340 inactive_toggle_text: config.catalog && config.catalog.i18n ? config.catalog.i18n.edit_rows : ''
341 } );
342 }
343 /**
344 * Stop page-changing catalog controls while a pending operation owns row state.
345 *
346 * Summary elements do not honor the disabled property, so this capture guard
347 * complements the visual disabled state while Service inline or bulk editing is active.
348 *
349 * @param {Event} event Captured catalog event.
350 * @return {void}
351 */
352 function protect_inline_drafts_from_catalog_controls( event ) {
353 var protected_control;
354
355 if ( inlineWorkflowController && inlineWorkflowController.protect_event( event, state.inline_editing || state.inline_schema_loading || !! state.operation_mode ) ) { return; }
356 if ( ( ! state.inline_editing && ! state.inline_schema_loading && ! state.operation_mode ) || ! event.target || ! event.target.closest ) { return; }
357 protected_control = event.target.closest( '.wpbc_appointment_services__status_filter, #wpbc_service_provider_filter, .wpbc_appointment_services__add' );
358 if ( ! protected_control ) { return; }
359 event.preventDefault();
360 event.stopImmediatePropagation();
361 }
362 /** Mark the page busy during a mutating request and refresh controls. */
363 function setBusy( value ) { state.busy = value; $( '.wpbc_appointment_services_page' ).toggleClass( 'is-busy', value ); updateControls(); }
364 /** Enable or disable all fields in the Service inspector. */
365 function setFieldsEnabled( enabled ) { $( '[data-service-field], [data-service-range-field], [data-service-status-choice]' ).prop( 'disabled', ! enabled ); updateControls(); }
366 /** Return defaults for a new unsaved Service. */
367 function blankService() {
368 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() };
369 }
370 /**
371 * Render the Service-owned create or edit inspector header template.
372 *
373 * Existing Service fields remain in their native collapsible groups so the
374 * template changes presentation without moving domain validation to shared code.
375 *
376 * @param {boolean} is_edit Whether an existing Service is being edited.
377 * @return {void}
378 */
379 function render_inspector_header( is_edit ) {
380 var template_id = is_edit ? 'wpbc-appointment-service-inspector-edit' : 'wpbc-appointment-service-inspector-create';
381 var template = catalogTemplate( template_id );
382 var $header = $( '[data-wpbc-appointment-service-inspector-header]' );
383 var context = is_edit
384 ? String( config.i18n.inspector_context_id || 'ID: %d' ).replace( '%d', String( state.selectedId ) )
385 : String( config.i18n.inspector_context_new || 'New' );
386
387 if ( ! template || ! $header.length ) {
388 return;
389 }
390
391 $header.html( template( {
392 title: is_edit ? config.i18n.edit_service_title : config.i18n.create_service_title,
393 context: context,
394 description: is_edit ? config.i18n.edit_service_description : config.i18n.create_service_description
395 } ) );
396 }
397 /** Populate the inspector from a normalized Service response. */
398 function fillEditor( service ) {
399 service = $.extend( blankService(), service || {} );
400 state.selectedId = Number( service.service_id || 0 );
401 render_inspector_header( 0 < state.selectedId );
402 $.each( service, function ( key, value ) { $( '[data-service-field="' + key + '"]' ).val( value ); } );
403 sync_status_radios();
404 sync_all_numeric_ranges();
405 updateMediaPreview();
406 setFieldsEnabled( state.storageReady );
407 $( '.wpbc_appointment_services__item' ).removeClass( 'is-inspector-selected' ).attr( 'aria-current', 'false' );
408 $( '.wpbc_appointment_services__item[data-service-id="' + state.selectedId + '"]' ).addClass( 'is-inspector-selected' ).attr( 'aria-current', 'true' );
409 capture_editor_snapshot();
410 }
411 /** Collect the current Service inspector values for saving. */
412 function collectEditor() {
413 var service = { service_id: state.selectedId };
414 $( '[data-service-field]' ).each( function () { service[ $( this ).data( 'service-field' ) ] = $( this ).val(); } );
415 return service;
416 }
417 /**
418 * Store the current editor values as the last loaded or saved state.
419 *
420 * @return {void}
421 */
422 function capture_editor_snapshot() {
423 state.editor_snapshot = JSON.stringify( collectEditor() );
424 }
425 /**
426 * Determine whether the open Service editor contains unsaved changes.
427 *
428 * @return {boolean} True when current fields differ from the captured state.
429 */
430 function is_editor_dirty() {
431 return editorIsOpen() && state.editor_snapshot !== JSON.stringify( collectEditor() );
432 }
433 /**
434 * Confirm before replacing an editor that contains unsaved Service changes.
435 *
436 * @return {boolean} True when replacing the current editor may continue.
437 */
438 function can_replace_editor() {
439 return ! is_editor_dirty() || w.confirm( config.i18n.confirm_discard || 'Discard unsaved Service changes?' );
440 }
441 /**
442 * Determine whether the current Service operation contains unapplied values.
443 *
444 * Inline drafts and signed review screens remain dirty until they are
445 * explicitly applied or discarded. This prevents an outside click from
446 * silently replacing a reviewed mutation.
447 *
448 * @return {boolean} True when closing would discard pending changes.
449 */
450 function is_operation_dirty() {
451 if ( 'bulk_edit' === state.operation_mode ) {
452 return !! collect_bulk_changes();
453 }
454
455 if ( 'inline_review' === state.operation_mode || 'bulk_review' === state.operation_mode || 'delete_review' === state.operation_mode ) {
456 return !! state.operation_review;
457 }
458
459 return state.inline_editing && 0 < get_inline_changed_count();
460 }
461 /**
462 * Clear the Service editor after its native sidebar has been closed.
463 *
464 * Invalidating the request sequence prevents a late load response from
465 * reopening an inspector that the user already dismissed.
466 *
467 * @return {void}
468 */
469 function reset_service_editor() {
470 var service = blankService();
471
472 state.editor_request_sequence += 1;
473 state.selectedId = 0;
474 state.requested_focus = '';
475 state.focus_handled = true;
476 render_inspector_header( false );
477 $.each( service, function ( key, value ) { $( '[data-service-field="' + key + '"]' ).val( value ); } );
478 sync_status_radios();
479 sync_all_numeric_ranges();
480 updateMediaPreview();
481 setFieldsEnabled( false );
482 $( '.wpbc_appointment_services__item' ).removeClass( 'is-inspector-selected' ).attr( 'aria-current', 'false' );
483 capture_editor_snapshot();
484 updateUrl( 0 );
485 }
486 /**
487 * Clear a Service inline or bulk operation without changing selection.
488 *
489 * @param {boolean} restore_inline Whether inline rows should return to presentation mode.
490 * @return {void}
491 */
492 function reset_operation( restore_inline ) {
493 state.operation_request_sequence += 1;
494 state.operation_mode = '';
495 state.operation_review = null;
496 $( '[data-wpbc-appointment-services-operation-host]' ).empty().prop( 'hidden', true );
497 $( '[data-wpbc-appointment-services-native-inspector]' ).prop( 'hidden', false );
498 $( '.wpbc_appointment_services__operation_apply' )
499 .removeClass( 'wpbc_ui_catalog_delete_review__apply button-secondary is-busy' )
500 .addClass( 'button-primary' )
501 .removeAttr( 'aria-busy form' )
502 .text( config.i18n.apply_changes || 'Apply changes' );
503 if ( restore_inline ) {
504 state.inline_editing = false;
505 state.inline_drafts = {};
506 state.inline_schema = {};
507 state.inline_schema_loading = false;
508 state.inline_request_sequence += 1;
509 $( '[data-wpbc-appointment-services-inline-bar-host]' ).empty();
510 if ( state.last_response ) {
511 renderCatalogResponse( state.last_response );
512 }
513 }
514 setBusy( false );
515 }
516 /**
517 * Close the Service inspector using the Booking Resources lifecycle.
518 *
519 * @param {boolean} confirm_discard Whether to confirm unsaved changes.
520 * @param {boolean} hide_sidebar Whether this function must hide the native sidebar.
521 * @return {boolean} True when the inspector was closed.
522 */
523 function close_service_inspector( confirm_discard, hide_sidebar ) {
524 var focus_target = state.inspector_focus_target;
525
526 if ( state.mutation_in_progress ) {
527 return false;
528 }
529
530 if ( state.operation_mode ) {
531 if ( confirm_discard && is_operation_dirty() && ! w.confirm( config.i18n.confirm_discard || 'Discard unsaved Service changes?' ) ) {
532 return false;
533 }
534 reset_operation( true );
535 state.inspector_focus_target = null;
536 if ( hide_sidebar && 'function' === typeof w.wpbc_admin_ui__sidebar_right__do_hide ) {
537 w.wpbc_admin_ui__sidebar_right__do_hide();
538 }
539 notify_setup_wizard_layout_changed();
540 if ( hide_sidebar && focus_target && document.documentElement.contains( focus_target ) && 'function' === typeof focus_target.focus ) {
541 focus_target.focus();
542 }
543 return true;
544 }
545
546 if ( confirm_discard && ! can_replace_editor() ) {
547 return false;
548 }
549
550 reset_service_editor();
551 state.inspector_focus_target = null;
552 if ( hide_sidebar && 'function' === typeof w.wpbc_admin_ui__sidebar_right__do_hide ) {
553 w.wpbc_admin_ui__sidebar_right__do_hide();
554 }
555 notify_setup_wizard_layout_changed();
556 if ( hide_sidebar && focus_target && document.documentElement.contains( focus_target ) && 'function' === typeof focus_target.focus ) {
557 focus_target.focus();
558 }
559
560 return true;
561 }
562 /** Reflect the selected Service in the admin URL without reloading. */
563 function updateUrl( serviceId ) {
564 if ( ! w.history || ! w.URL ) { return; }
565 var url = new w.URL( w.location.href );
566 if ( serviceId ) { url.searchParams.set( 'service_id', serviceId ); } else { url.searchParams.delete( 'service_id' ); }
567 w.history.replaceState( {}, '', url.toString() );
568 }
569 /** Index Provider presentation records by booking resource ID. */
570 function indexProviders( providers ) {
571 state.providers = {};
572 $.each( providers || [], function ( index, provider ) { state.providers[ String( provider.id ) ] = provider; } );
573 }
574 /** Update status and Provider counters above the Service table. */
575 function updateSummary( counts, providerCount ) {
576 counts = $.extend( { all: 0, active: 0, inactive: 0, archived: 0 }, counts || {} );
577 state.providerCount = Number( providerCount || 0 );
578 $.each( counts, function ( status, count ) { $( '[data-service-count="' + status + '"]' ).text( Number( count || 0 ) ); } );
579 $( '[data-provider-count]' ).text( state.providerCount );
580 $( '.wpbc_appointment_services__provider_notice' ).prop( 'hidden', 0 !== state.providerCount );
581 }
582 /** Format a normalized Service cost using the configured currency symbol. */
583 function formatCost( cost ) {
584 var amount = Number( cost || 0 );
585 var symbol = config.currency_symbol || '$';
586 return symbol + amount.toFixed( 2 );
587 }
588 /** Build compact Provider avatar nodes for one Service row. */
589 function providerNodes( service ) {
590 var ids = $.map( service.resource_ids || [], function ( value ) { return Number( value || 0 ); } );
591 var $stack = $( '<div>', { 'class': 'wpbc_appointment_services__provider_stack' } );
592 if ( ! ids.length ) { return $( '<span>', { 'class': 'wpbc_appointment_services__no_provider', text: config.i18n.no_provider || 'No Providers assigned' } ); }
593 $.each( ids.slice( 0, 3 ), function ( index, id ) {
594 var provider = state.providers[ String( id ) ] || { id: id, title: 'Provider #' + id, initials: 'P', avatar_url: '' };
595 var has_availability = false !== provider.has_weekly_availability;
596 var provider_title = provider.title || 'Provider #' + id;
597 var avatar_title = provider_title;
598 var avatar_attributes;
599 var $avatar;
600
601 if ( ! has_availability ) { avatar_title += ' — ' + ( config.i18n.no_availability || 'No weekly availability' ); }
602 avatar_attributes = {
603 'class': 'wpbc_appointment_services__provider_avatar' + ( has_availability ? '' : ' has-no-availability' ),
604 title: avatar_title
605 };
606 if ( provider.availability_url ) {
607 avatar_title = String( config.i18n.edit_availability || 'Edit availability for %s' ).replace( '%s', provider_title );
608 avatar_attributes.href = provider.availability_url;
609 avatar_attributes.title = avatar_title;
610 avatar_attributes[ 'aria-label' ] = avatar_title;
611 $avatar = $( '<a>', avatar_attributes );
612 } else {
613 $avatar = $( '<span>', avatar_attributes );
614 }
615 if ( provider.avatar_url ) { $( '<img>', { src: provider.avatar_url, alt: '', loading: 'lazy' } ).appendTo( $avatar ); }
616 else { $avatar.text( provider.initials || 'P' ); }
617 $stack.append( $avatar );
618 } );
619 if ( ids.length > 3 ) { $( '<span>', { 'class': 'wpbc_appointment_services__provider_more', text: '+' + ( ids.length - 3 ), title: ( ids.length - 3 ) + ' ' + ( config.i18n.more_providers || 'more Providers' ) } ).appendTo( $stack ); }
620 return $stack;
621 }
622 /**
623 * Build the Service thumbnail used in the management table.
624 *
625 * @param {Object} service Normalized Service response.
626 * @return {jQuery} Thumbnail wrapper containing an image or placeholder icon.
627 */
628 function serviceThumbnailNode( service ) {
629 var pictureUrl = String( service.picture_url || '' ).trim();
630 var service_title = String( service.title || config.i18n.untitled || 'Untitled Service' );
631 var service_description = String( service.description || '' ).trim() || config.i18n.no_description || 'No description';
632 var tooltip_format = String( config.i18n.service_thumbnail_tooltip || 'Title: %1$s\nDescription: %2$s' );
633 var tooltip_text = tooltip_format.replace( '%1$s', service_title ).replace( '%2$s', service_description );
634 var $thumbnail = $( '<span>', {
635 'class': 'wpbc_ui_listing__table_icon wpbc_appointment_services__service_thumbnail tooltip_top',
636 'data-original-title': tooltip_text,
637 'aria-label': tooltip_text,
638 role: 'img',
639 tabindex: '0'
640 } );
641 if ( pictureUrl ) {
642 $( '<img>', { src: pictureUrl, alt: '', loading: 'lazy', decoding: 'async' } ).appendTo( $thumbnail );
643 } else {
644 $( '<i>', { 'class': 'menu_icon icon-1x wpbc-bi-image-fill', 'aria-hidden': 'true' } ).appendTo( $thumbnail );
645 }
646 return $thumbnail;
647 }
648 /**
649 * Destroy Service thumbnail tooltips before AJAX replaces their elements.
650 *
651 * @return {void}
652 */
653 function destroy_service_thumbnail_tooltips() {
654 $( '.wpbc_appointment_services__service_thumbnail' ).each( function () {
655 if ( this._tippy && 'function' === typeof this._tippy.destroy ) {
656 this._tippy.destroy();
657 }
658 } );
659 }
660 /**
661 * Initialize Service thumbnail tooltips after an AJAX listing render.
662 *
663 * The native title attribute is used only when the Booking Calendar Tippy
664 * helper is unavailable, avoiding duplicate browser and Tippy tooltips.
665 *
666 * @return {void}
667 */
668 function refresh_service_thumbnail_tooltips() {
669 var listing_selector = '#wpbc_appointment_services_catalog ';
670 var $thumbnails = $( listing_selector + '.wpbc_appointment_services__service_thumbnail' );
671 var tooltips_initialized = false;
672
673 if ( 'function' === typeof w.wpbc_define_tippy_tooltips ) {
674 tooltips_initialized = w.wpbc_define_tippy_tooltips( listing_selector );
675 }
676 if ( tooltips_initialized ) {
677 return;
678 }
679 $thumbnails.each( function () {
680 $( this ).attr( 'title', $( this ).attr( 'data-original-title' ) || '' );
681 } );
682 }
683 /**
684 * Build the compact duration and before/after buffer summary for one Service.
685 *
686 * @param {Object} service Normalized Service response.
687 * @return {jQuery} Duration details wrapper for the listing column.
688 */
689 function service_duration_node( service ) {
690 var duration_minutes = Math.max( 0, Number( service.duration_minutes || 0 ) );
691 var buffer_before_minutes = Math.max( 0, Number( service.buffer_before_minutes || 0 ) );
692 var buffer_after_minutes = Math.max( 0, Number( service.buffer_after_minutes || 0 ) );
693 var duration_format = String( config.i18n.duration_minutes || '%s min' );
694 var buffers_format = String( config.i18n.buffers_summary || 'Buffers: %1$s / %2$s min' );
695 var buffers_tooltip_format = String( config.i18n.buffers_tooltip || 'Buffer before: %1$s min; Buffer after: %2$s min' );
696 var buffers_summary = buffers_format.replace( '%1$s', buffer_before_minutes ).replace( '%2$s', buffer_after_minutes );
697 var buffers_tooltip = buffers_tooltip_format.replace( '%1$s', buffer_before_minutes ).replace( '%2$s', buffer_after_minutes );
698 var $duration_details = $( '<span>', { 'class': 'wpbc_appointment_services__duration_details' } );
699
700 $( '<strong>', {
701 'class': 'wpbc_appointment_services__duration_value',
702 text: duration_format.replace( '%s', duration_minutes )
703 } ).appendTo( $duration_details );
704 $( '<span>', {
705 'class': 'wpbc_appointment_services__buffers_summary',
706 text: buffers_summary,
707 title: buffers_tooltip,
708 'aria-label': buffers_tooltip
709 } ).appendTo( $duration_details );
710
711 return $duration_details;
712 }
713 /**
714 * Return assigned Providers with recurring availability on one weekday.
715 *
716 * @param {Object} service Normalized Service response.
717 * @param {string} day Weekday key from mon through sun.
718 * @return {Array<Object>} Matching Provider presentation records.
719 */
720 function providers_available_on( service, day ) {
721 var available_providers = [];
722 $.each( service.resource_ids || [], function ( index, id ) {
723 var provider = state.providers[ String( Number( id || 0 ) ) ];
724 if ( provider && provider.weekdays && provider.weekdays[ day ] ) {
725 available_providers.push( provider );
726 }
727 } );
728
729 return available_providers;
730 }
731 /**
732 * Build compact Provider-specific links below the weekly availability dots.
733 *
734 * @param {Object} service Normalized Service response.
735 * @return {jQuery} Availability links, or an empty collection when unavailable.
736 */
737 function availability_edit_links( service ) {
738 var $links = $( '<div>', {
739 'class': 'wpbc_appointment_services__availability_links',
740 'aria-label': config.i18n.edit_provider_availability || 'Edit Provider availability'
741 } );
742
743 $.each( service.resource_ids || [], function ( index, id ) {
744 var provider = state.providers[ String( Number( id || 0 ) ) ];
745 var provider_title;
746 var link_title;
747
748 if ( ! provider || ! provider.availability_url ) {
749 return;
750 }
751 provider_title = provider.title || 'Provider #' + Number( id || 0 );
752 link_title = String( config.i18n.edit_availability || 'Edit availability for %s' ).replace( '%s', provider_title );
753 $( '<a>', {
754 'class': 'wpbc_appointment_services__availability_link',
755 href: provider.availability_url,
756 text: provider.initials || 'P',
757 title: link_title,
758 'aria-label': link_title
759 } ).appendTo( $links );
760 } );
761
762 return $links.children().length ? $links : $();
763 }
764 /** Convert a stored Service status to its translated UI label. */
765 function statusLabel( status ) {
766 if ( 'inactive' === status ) { return config.i18n.draft || 'Draft'; }
767 if ( 'archived' === status ) { return config.i18n.archived || 'Archived'; }
768 return config.i18n.active || 'Active';
769 }
770 /** Format the translated table pagination summary. */
771 function showingText( from, to, total ) {
772 var format = config.i18n.showing || 'Showing %1$s-%2$s of %3$s Services';
773 return format.replace( '%1$s', from ).replace( '%2$s', to ).replace( '%3$s', total );
774 }
775 /** Return one compiled, allow-listed WordPress template. */
776 function catalogTemplate( templateId ) {
777 try { return templateId && w.wp && 'function' === typeof w.wp.template ? w.wp.template( templateId ) : null; }
778 catch ( error ) { return null; }
779 }
780 /**
781 * Return the shared native inspector state workflow for Service operations.
782 *
783 * @return {Object|false} Shared inspector workflow or false.
784 */
785 function get_operation_inspector_workflow() {
786 var template_id;
787
788 if ( inspectorWorkflowController ) { return inspectorWorkflowController; }
789 if ( ! w.wpbc_ui_catalog || 'function' !== typeof w.wpbc_ui_catalog.create_inspector_workflow ) { return false; }
790 template_id = config.catalog && config.catalog.templates ? config.catalog.templates.inspector : '';
791 inspectorWorkflowController = w.wpbc_ui_catalog.create_inspector_workflow( {
792 expand: function () {
793 $( '[data-wpbc-appointment-services-operation-host]' ).prop( 'hidden', false );
794 $( '[data-wpbc-appointment-services-native-inspector]' ).prop( 'hidden', true );
795 expand_service_inspector();
796 },
797 get_footer: function () { return document.querySelector( '.wpbc_appointment_services__right_sidebar_footer' ); },
798 get_host: function () { return document.querySelector( '[data-wpbc-appointment-services-operation-host]' ); },
799 render_shell: function ( shell_data ) {
800 var shell_template = catalogTemplate( template_id );
801
802 return shell_template ? shell_template( shell_data ) : '';
803 },
804 shell_data: {
805 catalog_id: config.catalog && config.catalog.id ? config.catalog.id : 'appointment_services_catalog',
806 empty_icon: 'wpbc-bi-pencil-square',
807 empty_message: '',
808 empty_title: '',
809 loading_label: config.i18n.loading || ''
810 }
811 } );
812
813 return inspectorWorkflowController;
814 }
815 /** Return safe HTML from a detached jQuery presentation node. */
816 function nodeHtml( $node ) { return $node && $node.length ? $( '<div>' ).append( $node ).html() : ''; }
817 /**
818 * Return the shared selection controller mounted for the Services catalog.
819 *
820 * @return {Object|null} Shared selection controller, or null before mount.
821 */
822 function get_selection_controller() {
823 var mount = document.getElementById( 'wpbc_appointment_services_catalog' );
824
825 return mount && mount._wpbc_ui_catalog_selection_controller ? mount._wpbc_ui_catalog_selection_controller : null;
826 }
827 /**
828 * Return selected Service identifiers without exposing selection internals.
829 *
830 * @return {Array<number>} Persisted selected Service identifiers.
831 */
832 function get_selected_service_ids() {
833 var selection = get_selection_controller();
834
835 return selection && 'function' === typeof selection.get_selected_ids ? selection.get_selected_ids() : [];
836 }
837 /**
838 * Return one Service from the last normalized catalog response.
839 *
840 * @param {number|string} service_id Service identifier to find.
841 * @return {Object|null} Matching Service DTO, or null when not on this page.
842 */
843 function find_service( service_id ) {
844 var found = null;
845
846 $.each( state.services, function ( index, service ) {
847 if ( Number( service.service_id || service.id || 0 ) === Number( service_id ) ) {
848 found = service;
849 return false;
850 }
851 } );
852
853 return found;
854 }
855 /**
856 * Return the allow-listed initial inline draft for one Service.
857 *
858 * Provider assignments, status, and buffers are deliberately omitted because
859 * they require the reviewed bulk inspector or the complete Service inspector.
860 *
861 * @param {Object} row_schema Server-authoritative row schema.
862 * @return {Object} Editable row-specific draft.
863 */
864 function create_inline_draft( row_schema ) {
865 var draft = {};
866
867 $.each( row_schema && Array.isArray( row_schema.fields ) ? row_schema.fields : [], function ( index, field ) {
868 var field_key = String( field && field.key ? field.key : '' );
869 if ( field_key ) { draft[ field_key ] = String( field.value ); }
870 } );
871
872 return draft;
873 }
874 /** Return one cached server-authoritative inline row schema. */
875 function find_inline_schema( service_id ) {
876 return state.inline_schema[ String( Number( service_id || 0 ) ) ] || null;
877 }
878 /**
879 * Return whether one inline draft differs from its current Service DTO.
880 *
881 * @param {Object|null} row_schema Current server-authoritative row schema.
882 * @param {Object|null} draft Row-specific inline draft.
883 * @return {boolean} True when at least one allow-listed value changed.
884 */
885 function inline_draft_changed( row_schema, draft ) {
886 var changed = false;
887
888 if ( ! row_schema || ! draft ) { return false; }
889 $.each( Array.isArray( row_schema.fields ) ? row_schema.fields : [], function ( index, field ) {
890 if ( String( field.value ) !== String( draft[ field.key ] ) ) {
891 changed = true;
892 return false;
893 }
894 } );
895
896 return changed;
897 }
898 /**
899 * Return changed inline drafts keyed by Service ID.
900 *
901 * @return {Object<string,Object>} Changed drafts keyed by Service identifier.
902 */
903 function collect_inline_changes() {
904 var changes = {};
905
906 $.each( state.inline_drafts, function ( service_id, draft ) {
907 var row_schema = find_inline_schema( service_id );
908 var row_changes = {};
909
910 $.each( row_schema && Array.isArray( row_schema.fields ) ? row_schema.fields : [], function ( index, field ) {
911 if ( String( field.value ) !== String( draft[ field.key ] ) ) {
912 row_changes[ field.key ] = draft[ field.key ];
913 }
914 } );
915 if ( Object.keys( row_changes ).length ) {
916 changes[ service_id ] = row_changes;
917 }
918 } );
919
920 return changes;
921 }
922 /**
923 * Return the count of changed Service rows in inline mode.
924 *
925 * @return {number} Number of row drafts that differ from their DTOs.
926 */
927 function get_inline_changed_count() { return Object.keys( collect_inline_changes() ).length; }
928 /**
929 * Render and register the sticky inline-editing status bar.
930 *
931 * Registration delegates viewport positioning to the shared selection
932 * controller so the Service page follows the Resource catalog behavior.
933 *
934 * @return {void}
935 */
936 function render_inline_bar() {
937 var template = catalogTemplate( 'wpbc-appointment-services-inline-bar' );
938 var changed_count = get_inline_changed_count();
939 var $host = $( '[data-wpbc-appointment-services-inline-bar-host]' );
940
941 if ( ! template || ! $host.length ) { return; }
942 if ( ! $host.children().length ) {
943 $host.html( template( {
944 title: config.i18n.editing_rows,
945 changed_label: String( config.i18n.changed_rows || '%s changed rows' ).replace( '%s', changed_count ),
946 description: config.i18n.inline_help,
947 cancel: config.i18n.cancel,
948 review: config.i18n.review_changes,
949 changed_count: changed_count
950 } ) );
951 }
952 synchronize_inline_bar();
953 }
954 /**
955 * Synchronize the inline bar without replacing its active controls.
956 *
957 * A focused inline field emits its final change event while the Review
958 * button is being clicked. Replacing the bar from that change handler would
959 * remove the pointer target before the click event can complete.
960 *
961 * @return {void}
962 */
963 function synchronize_inline_bar() {
964 var changed_count = get_inline_changed_count();
965 var $bar = $( '[data-wpbc-appointment-services-inline-bar]' );
966
967 if ( ! $bar.length ) { return; }
968 $bar.find( '[data-wpbc-appointment-services-inline-changed-label]' ).text(
969 String( config.i18n.changed_rows || '%s changed rows' ).replace( '%s', changed_count )
970 );
971 synchronize_inline_workflow();
972 }
973 /**
974 * Start row-specific inline editing for the current catalog page.
975 *
976 * Drafts intentionally cover the current page only. Page-changing controls
977 * remain protected until the user cancels or completes the reviewed change.
978 *
979 * @return {void}
980 */
981 function start_inline_editing() {
982 var request_sequence;
983 var visible_ids;
984
985 if ( state.busy || state.catalog_loading || state.operation_mode ) { return; }
986 if ( state.inline_editing ) {
987 cancel_inline_editing( true );
988 return;
989 }
990 if ( ! state.services.length ) { return; }
991 if ( ! can_replace_editor() ) { return; }
992 if ( editorIsOpen() ) { reset_service_editor(); }
993 visible_ids = $.map( state.services, function ( service ) { return Number( service.service_id || service.id || 0 ); } );
994 request_sequence = ++state.inline_request_sequence;
995 state.inline_editing = false;
996 state.inline_drafts = {};
997 state.inline_schema = {};
998 state.inline_schema_loading = true;
999 setBusy( true );
1000 request( config.actions.inline_schema, { ids: JSON.stringify( visible_ids ) } ).done( function ( response ) {
1001 var schema = response && response.success && response.data ? response.data.schema : null;
1002
1003 if ( request_sequence !== state.inline_request_sequence ) { return; }
1004 if ( ! schema || ! Array.isArray( schema.rows ) ) {
1005 notify( messageFrom( response, config.i18n.inline_schema_failed ), 'error' );
1006 return;
1007 }
1008 $.each( schema.rows, function ( index, row_schema ) {
1009 var service_id = String( Number( row_schema.service_id || 0 ) );
1010 if ( '0' === service_id || ! Array.isArray( row_schema.fields ) || ! row_schema.fields.length ) { return; }
1011 state.inline_schema[ service_id ] = row_schema;
1012 state.inline_drafts[ service_id ] = create_inline_draft( row_schema );
1013 } );
1014 if ( ! Object.keys( state.inline_drafts ).length ) {
1015 notify( config.i18n.inline_schema_failed, 'error' );
1016 return;
1017 }
1018 state.inline_editing = true;
1019 renderCatalogResponse( state.last_response );
1020 render_inline_bar();
1021 $( '[data-wpbc-appointment-services-inline-field]' ).first().trigger( 'focus' );
1022 } ).fail( function ( xhr ) {
1023 if ( request_sequence === state.inline_request_sequence ) {
1024 notify( messageFrom( xhr.responseJSON, config.i18n.inline_schema_failed ), 'error' );
1025 }
1026 } ).always( function () {
1027 if ( request_sequence === state.inline_request_sequence ) {
1028 state.inline_schema_loading = false;
1029 setBusy( false );
1030 }
1031 } );
1032 }
1033 /**
1034 * Cancel Service inline editing and restore focus to its toolbar action.
1035 *
1036 * The stable shared data attribute is the interaction contract. Keeping the
1037 * cancellation in this domain adapter preserves ownership of Service drafts
1038 * while preventing surrounding page and sidebar click handlers from deciding
1039 * whether those drafts should be discarded.
1040 *
1041 * @param {boolean} confirm_discard Whether changed drafts need confirmation.
1042 * @return {boolean} True when inline editing was cancelled.
1043 */
1044 function cancel_inline_editing( confirm_discard ) {
1045 var focus_target = document.querySelector( '[data-wpbc-ui-catalog-inline-toggle]' );
1046
1047 if ( state.mutation_in_progress ) { return false; }
1048 if ( confirm_discard && get_inline_changed_count() && ! w.confirm( config.i18n.confirm_discard || 'Discard unsaved Service changes?' ) ) {
1049 return false;
1050 }
1051 reset_operation( true );
1052 if ( focus_target && document.documentElement.contains( focus_target ) && 'function' === typeof focus_target.focus ) {
1053 focus_target.focus();
1054 }
1055
1056 return true;
1057 }
1058 /**
1059 * Return enabled values from the Service bulk editor.
1060 *
1061 * @return {Object<string,string>|null} Shared changes, or null when none enabled.
1062 */
1063 function collect_bulk_changes() {
1064 var changes = {};
1065
1066 $( '[data-wpbc-appointment-services-bulk-enable]:checked' ).each( function () {
1067 var field_id = String( $( this ).data( 'wpbc-appointment-services-bulk-enable' ) || '' );
1068 var $field = $( '[data-wpbc-appointment-services-bulk-value="' + field_id + '"]' );
1069 if ( field_id && $field.length ) { changes[ field_id ] = $field.val(); }
1070 } );
1071
1072 return Object.keys( changes ).length ? changes : null;
1073 }
1074 /**
1075 * Open a Service-owned operation inside the native right inspector.
1076 *
1077 * @param {string} mode Operation state identifier.
1078 * @param {string} template_id Allow-listed WordPress template ID.
1079 * @param {Object} template_data Presentation data for the template.
1080 * @param {HTMLElement} focus_target Element that should regain focus on close.
1081 * @return {boolean} True when the operation template was opened.
1082 */
1083 function open_operation( mode, template_id, template_data, focus_target ) {
1084 var inspector_workflow = get_operation_inspector_workflow();
1085 var template = catalogTemplate( template_id );
1086 var $host = $( '[data-wpbc-appointment-services-operation-host]' );
1087 var rendered_operation;
1088 var target;
1089
1090 if ( ! template || ! $host.length || ! inspector_workflow || ! inspector_workflow.mount() ) {
1091 state.operation_mode = '';
1092 state.operation_review = null;
1093 updateControls();
1094 notify( config.i18n.operation_failed || config.i18n.load_failed, 'error' );
1095 return false;
1096 }
1097
1098 try {
1099 rendered_operation = template( template_data );
1100 } catch ( error ) {
1101 state.operation_mode = '';
1102 state.operation_review = null;
1103 $host.empty().prop( 'hidden', true );
1104 $( '[data-wpbc-appointment-services-native-inspector]' ).prop( 'hidden', false );
1105 updateControls();
1106 notify( config.i18n.operation_failed || config.i18n.load_failed, 'error' );
1107 return false;
1108 }
1109
1110 state.operation_mode = mode;
1111 state.inspector_focus_target = focus_target || document.activeElement;
1112 target = inspector_workflow.get_form_target();
1113 if ( ! target ) {
1114 state.operation_mode = '';
1115 state.operation_review = null;
1116 inspector_workflow.set_state( 'error', config.i18n.operation_failed || config.i18n.load_failed );
1117 updateControls();
1118 notify( config.i18n.operation_failed || config.i18n.load_failed, 'error' );
1119 return false;
1120 }
1121 target.innerHTML = rendered_operation;
1122 inspector_workflow.set_state( 'form', '' );
1123 $host.prop( 'hidden', false );
1124 $( '[data-wpbc-appointment-services-native-inspector]' ).prop( 'hidden', true );
1125 expand_service_inspector();
1126 updateControls();
1127 $host.find( '[data-wpbc-ui-catalog-delete-review-heading], [data-wpbc-ui-catalog-inline-review-heading], h2' ).first().attr( 'tabindex', '-1' ).trigger( 'focus' );
1128
1129 return true;
1130 }
1131 /**
1132 * Open bulk editing for the current persistent Service selection.
1133 *
1134 * @param {HTMLElement} focus_target Element that opened the bulk inspector.
1135 * @return {void}
1136 */
1137 function open_bulk_edit( focus_target ) {
1138 var selected_ids = get_selected_service_ids();
1139
1140 if ( ! selected_ids.length || state.busy || state.inline_editing ) { return; }
1141 if ( ! can_replace_editor() ) { return; }
1142 if ( editorIsOpen() ) { reset_service_editor(); }
1143 setBusy( true );
1144 request( config.actions.bulk_contract, { ids: JSON.stringify( selected_ids ) } ).done( function ( response ) {
1145 var contract = response && response.success && response.data ? response.data.contract : null;
1146
1147 if ( ! contract || ! Array.isArray( contract.fields ) || ! contract.fields.length ) {
1148 notify( messageFrom( response, config.i18n.bulk_contract_failed ), 'error' );
1149 return;
1150 }
1151 open_operation( 'bulk_edit', 'wpbc-appointment-services-bulk-edit', {
1152 title: config.i18n.bulk_edit_title,
1153 description: contract.message || config.i18n.bulk_edit_description,
1154 fields: contract.fields
1155 }, focus_target );
1156 } ).fail( function ( xhr ) {
1157 notify( messageFrom( xhr.responseJSON, config.i18n.bulk_contract_failed ), 'error' );
1158 } ).always( function () { setBusy( false ); } );
1159 }
1160 /**
1161 * Render a signed inline or bulk review in the Service inspector.
1162 *
1163 * The preview endpoint is non-mutating. Only the returned signed plan can be
1164 * submitted to the separate apply endpoint.
1165 *
1166 * @param {string} mode Either inline or bulk.
1167 * @param {Array<number>} ids Service identifiers to review.
1168 * @param {Object} changes Row-specific or shared field changes.
1169 * @param {HTMLElement} focus_target Element that opened the review.
1170 * @return {void}
1171 */
1172 function preview_operation( mode, ids, changes, focus_target ) {
1173 var inspector_workflow;
1174 var request_sequence;
1175
1176 if ( state.busy || ! ids.length || ! changes ) { return; }
1177 inspector_workflow = get_operation_inspector_workflow();
1178 request_sequence = ++state.operation_request_sequence;
1179 state.operation_mode = 'loading';
1180 state.operation_review = null;
1181 state.inspector_focus_target = focus_target || document.activeElement;
1182 if ( ! inspector_workflow || ! inspector_workflow.open_loading() ) {
1183 reset_operation( false );
1184 notify( config.i18n.operation_failed || config.i18n.preview_failed, 'error' );
1185 return;
1186 }
1187 setBusy( true );
1188 request( config.actions.preview, {
1189 mode: mode,
1190 ids: JSON.stringify( ids ),
1191 changes: JSON.stringify( changes )
1192 }, false ).done( function ( response ) {
1193 var review = response && response.success ? response.data : null;
1194 var review_workflow = get_inline_review_workflow();
1195 var review_rows;
1196 var review_model;
1197 var template_id = 'inline' === mode ? 'wpbc-appointment-services-inline-review' : 'wpbc-appointment-services-bulk-review';
1198 if ( request_sequence !== state.operation_request_sequence ) { return; }
1199 if ( ! review || ! review.plan || ! review.token ) {
1200 var error_message = messageFrom( response, config.i18n.preview_failed );
1201 inspector_workflow.set_state( 'error', error_message );
1202 notify( error_message, 'error' );
1203 return;
1204 }
1205 state.operation_review = review;
1206 review_rows = review.review && Array.isArray( review.review.rows ) ? review.review.rows : [];
1207 review_model = review_workflow ? review_workflow.prepare( review.review || {}, {
1208 changed_label: String( config.i18n.changed_rows || '%s changed rows' ).replace( '%s', String( review_rows.length ) ),
1209 description: config.i18n.review_confirmation || '',
1210 form_id: 'wpbc_appointment_services_' + mode + '_review_form',
1211 mode: mode + '_review',
1212 pending_message: config.i18n.review_description || '',
1213 title: 'inline' === mode ? config.i18n.inline_review_title : config.i18n.bulk_review_title
1214 } ) : {};
1215 if ( ! open_operation( mode + '_review', template_id, review_model, focus_target ) ) {
1216 inspector_workflow.set_state( 'error', config.i18n.operation_failed || config.i18n.preview_failed );
1217 return;
1218 }
1219 if ( review_workflow ) { review_workflow.synchronize( { busy: false, can_apply: true } ); }
1220 } ).fail( function ( xhr ) {
1221 var error_message = messageFrom( xhr.responseJSON, config.i18n.preview_failed );
1222 if ( request_sequence !== state.operation_request_sequence ) { return; }
1223 state.operation_mode = 'loading';
1224 inspector_workflow.set_state( 'error', error_message );
1225 notify( error_message, 'error' );
1226 } ).always( function () {
1227 if ( request_sequence === state.operation_request_sequence ) { setBusy( false ); }
1228 } );
1229 }
1230 /**
1231 * Open a loading inspector immediately and request a signed deletion review.
1232 *
1233 * @param {Array<number>} ids Selected Service identifiers.
1234 * @param {HTMLElement} focus_target Control that opened the review.
1235 * @return {void}
1236 */
1237 function preview_deletion( ids, focus_target ) {
1238 var inspector_workflow;
1239 var request_sequence;
1240
1241 ids = Array.isArray( ids ) ? ids.map( Number ).filter( function ( service_id ) { return service_id > 0; } ) : [];
1242 if ( state.busy || ! ids.length || state.inline_editing ) { return; }
1243 if ( ! can_replace_editor() ) { return; }
1244 if ( editorIsOpen() ) { reset_service_editor(); }
1245 inspector_workflow = get_operation_inspector_workflow();
1246 request_sequence = ++state.operation_request_sequence;
1247 state.operation_mode = 'loading';
1248 state.operation_review = null;
1249 state.inspector_focus_target = focus_target || document.activeElement;
1250 if ( ! inspector_workflow || ! inspector_workflow.open_loading() ) {
1251 reset_operation( false );
1252 notify( config.i18n.operation_failed || config.i18n.delete_preview_failed, 'error' );
1253 return;
1254 }
1255 setBusy( true );
1256 request( config.actions.delete_preview, { ids: JSON.stringify( ids ) }, false ).done( function ( response ) {
1257 var review = response && response.success ? response.data : null;
1258 var delete_review = review && review.delete_review ? review.delete_review : {};
1259 var delete_i18n = delete_review.i18n || {};
1260 var delete_workflow = get_delete_review_workflow();
1261 var review_model;
1262
1263 if ( request_sequence !== state.operation_request_sequence ) { return; }
1264 if ( ! review || ! review.plan || ! review.token ) {
1265 inspector_workflow.set_state( 'error', messageFrom( response, config.i18n.delete_preview_failed ) );
1266 return;
1267 }
1268 review_model = {
1269 acknowledgement: String( delete_i18n.acknowledgement || '' ),
1270 actions_heading: String( delete_i18n.actions_heading || '' ),
1271 can_apply: true === review.can_apply,
1272 description: String( delete_i18n.description || '' ),
1273 id_label: String( delete_i18n.id_label || config.i18n.column_id || 'ID' ),
1274 items: Array.isArray( delete_review.items ) ? delete_review.items : [],
1275 items_heading: String( delete_i18n.items_heading || '' ),
1276 pending_message: String( delete_i18n.pending_message || '' ),
1277 selection_label: String( delete_i18n.selection_label || '' ),
1278 title: String( delete_i18n.title || '' ),
1279 warning: String( delete_review.warning || review.warning || '' )
1280 };
1281 state.operation_review = review;
1282 if ( ! open_operation( 'delete_review', 'wpbc-appointment-services-delete-review', review_model, focus_target ) ) {
1283 inspector_workflow.set_state( 'error', config.i18n.delete_preview_failed );
1284 return;
1285 }
1286 if ( delete_workflow ) {
1287 delete_workflow.configure_footer( {
1288 can_apply: true === review.can_apply,
1289 footer: document.querySelector( '.wpbc_appointment_services__right_sidebar_footer' ),
1290 form_id: 'wpbc_appointment_services_delete_review_form',
1291 label: String( delete_i18n.delete_button || '' )
1292 } );
1293 delete_workflow.synchronize( { busy: false, can_apply: true === review.can_apply } );
1294 if ( true === review.can_apply ) { delete_workflow.pulse_acknowledgement(); }
1295 }
1296 } ).fail( function ( xhr ) {
1297 var error_message = messageFrom( xhr.responseJSON, config.i18n.delete_preview_failed );
1298 if ( request_sequence !== state.operation_request_sequence ) { return; }
1299 state.operation_mode = 'loading';
1300 inspector_workflow.set_state( 'error', error_message );
1301 notify( error_message, 'error' );
1302 } ).always( function () {
1303 if ( request_sequence === state.operation_request_sequence ) { setBusy( false ); }
1304 } );
1305 }
1306 /**
1307 * Apply the current signed Service review, then refresh the active page.
1308 *
1309 * Selection remains owned by the shared controller and is therefore restored
1310 * after the AJAX refresh instead of being silently cleared by the mutation.
1311 *
1312 * @return {void}
1313 */
1314 function apply_operation() {
1315 var review = state.operation_review;
1316 var changed_ids;
1317 var is_delete = 'delete_review' === state.operation_mode;
1318 var acknowledgement;
1319
1320 if ( state.busy || ! review || ! review.plan || ! review.token ) { return; }
1321 if ( is_delete ) {
1322 acknowledgement = document.querySelector( '[data-wpbc-ui-catalog-delete-acknowledgement]' );
1323 if ( true !== review.can_apply || ! acknowledgement || ! acknowledgement.checked ) {
1324 if ( get_delete_review_workflow() ) { get_delete_review_workflow().pulse_acknowledgement(); }
1325 return;
1326 }
1327 }
1328 state.mutation_in_progress = true;
1329 setBusy( true );
1330 if ( is_delete && get_delete_review_workflow() ) { get_delete_review_workflow().synchronize( { busy: true, can_apply: true } ); }
1331 else if ( get_inline_review_workflow() ) { get_inline_review_workflow().synchronize( { busy: true, can_apply: true } ); }
1332 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 ) {
1333 if ( ! response || ! response.success ) {
1334 notify( messageFrom( response, is_delete ? config.i18n.delete_apply_failed : config.i18n.apply_failed ), 'error' );
1335 return;
1336 }
1337 changed_ids = response.data && Array.isArray( response.data.changed_ids ) ? response.data.changed_ids.map( String ) : [];
1338 notify( response.data.message, 'success' );
1339 state.mutation_in_progress = false;
1340 close_service_inspector( false, true );
1341 pending_highlight_ids = is_delete ? [] : changed_ids;
1342 if ( is_delete && catalogController && 'function' === typeof catalogController.clear_selection ) { catalogController.clear_selection(); }
1343 if ( catalogController ) { catalogController.load( { page_number: state.page } ); }
1344 } ).fail( function ( xhr ) {
1345 notify( messageFrom( xhr.responseJSON, is_delete ? config.i18n.delete_apply_failed : config.i18n.apply_failed ), 'error' );
1346 } ).always( function () {
1347 state.mutation_in_progress = false;
1348 setBusy( false );
1349 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 } ); }
1350 else if ( get_inline_review_workflow() ) { get_inline_review_workflow().synchronize( { busy: false, can_apply: !! state.operation_review } ); }
1351 } );
1352 }
1353 /**
1354 * Highlight Services changed by the last reviewed mutation.
1355 *
1356 * The identifiers are retained until the refreshed catalog contains at least
1357 * one affected Service. This avoids consuming the highlight while closing an
1358 * operation re-renders the previous response.
1359 *
1360 * @return {void}
1361 */
1362 function apply_pending_highlights() {
1363 var first_service = null;
1364
1365 pending_highlight_ids.forEach( function ( service_id ) {
1366 var service = document.querySelector( '.wpbc_appointment_services__item[data-service-id="' + service_id + '"]' );
1367
1368 if ( service ) {
1369 service.classList.add( 'is-recently-saved' );
1370 first_service = first_service || service;
1371 }
1372 } );
1373 if ( ! first_service ) {
1374 return;
1375 }
1376 first_service.scrollIntoView( { block: 'nearest', behavior: 'smooth' } );
1377 window.setTimeout( function () {
1378 document.querySelectorAll( '.wpbc_appointment_services__item.is-recently-saved' ).forEach( function ( service ) {
1379 service.classList.remove( 'is-recently-saved' );
1380 } );
1381 }, 5000 );
1382 pending_highlight_ids = [];
1383 }
1384 /** Return the current ordered and visible Service column definitions. */
1385 function responseColumns( response ) {
1386 var definitions = config.catalog && config.catalog.columns ? config.catalog.columns.definitions || {} : {};
1387 var visible = response.display && response.display.visible_columns ? response.display.visible_columns : [];
1388 var order = response.display && response.display.column_order ? response.display.column_order : [];
1389 return $.map( order, function ( columnId ) {
1390 var definition = definitions[ columnId ];
1391 var is_sorted;
1392 if ( ! definition || -1 === visible.indexOf( columnId ) ) { return null; }
1393 is_sorted = !! definition.sort_key && definition.sort_key === response.sorting.sort_by;
1394 return {
1395 aria_sort: is_sorted ? ( 'desc' === response.sorting.sort_order ? 'descending' : 'ascending' ) : 'none',
1396 id: columnId,
1397 label: definition.label || columnId,
1398 class_name: definition.class || 'column-' + columnId,
1399 is_sorted: is_sorted,
1400 sort_icon: is_sorted ? ( 'desc' === response.sorting.sort_order ? 'wpbc-bi-arrow-down' : 'wpbc-bi-arrow-up' ) : 'wpbc_icn_import_export',
1401 sort_key: definition.sort_key || ''
1402 };
1403 } );
1404 }
1405 /**
1406 * Return sortable presentation records for the cards layout header.
1407 *
1408 * The table combines Status and Service ID in one column, so cards expand
1409 * that column into two independent sort controls without changing the DTO.
1410 *
1411 * @param {Object} response Normalized shared catalog response.
1412 * @return {Array<Object>} Visible, allow-listed cards sorting records.
1413 */
1414 function responseSortColumns( response ) {
1415 var columns = [];
1416 $.each( responseColumns( response ), function ( columnIndex, column ) {
1417 if ( 'status' === column.id ) {
1418 columns.push( {
1419 is_sorted: 'status' === response.sorting.sort_by,
1420 label: column.label,
1421 sort_icon: 'status' === response.sorting.sort_by ? ( 'desc' === response.sorting.sort_order ? 'wpbc-bi-arrow-down' : 'wpbc-bi-arrow-up' ) : 'wpbc_icn_import_export',
1422 sort_key: 'status'
1423 } );
1424 columns.push( {
1425 is_sorted: 'service_id' === response.sorting.sort_by,
1426 label: config.i18n.column_id || 'ID',
1427 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',
1428 sort_key: 'service_id'
1429 } );
1430 return;
1431 }
1432 if ( ! column.sort_key ) { return; }
1433 columns.push( {
1434 is_sorted: column.sort_key === response.sorting.sort_by,
1435 label: column.label,
1436 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',
1437 sort_key: column.sort_key
1438 } );
1439 } );
1440 return columns;
1441 }
1442 /**
1443 * Build presentation cells for the Service-owned row and card templates.
1444 *
1445 * Inline drafts replace only the allow-listed editable controls. Provider,
1446 * availability, status, and action presentation stays read-only.
1447 *
1448 * @param {Object} service Normalized Service DTO.
1449 * @return {Object} Escaped HTML fragments keyed by catalog column ID.
1450 */
1451 function serviceCells( service ) {
1452 var id = Number( service.service_id || service.id || 0 );
1453 var draft = state.inline_editing ? state.inline_drafts[ String( id ) ] : null;
1454 var row_schema = draft ? find_inline_schema( id ) : null;
1455 var inline_field_template = catalogTemplate( 'wpbc-appointment-service-inline-field' );
1456 var inline_cells = {};
1457 var title = service.title || config.i18n.untitled || 'Untitled Service';
1458 var description = String( service.description || '#' + id );
1459 var status = String( service.status || 'active' );
1460 var $identity = $( '<div>', { 'class': 'wpbc_appointment_services__service_identity' } );
1461 var identity_fields_class = draft
1462 ? 'wpbc_appointment_services__inline_identity_fields'
1463 : 'wpbc_ui_listing__item_copy wpbc_appointment_services__service_copy';
1464 var $copy = $( '<span>', { 'class': identity_fields_class } ).appendTo( $identity );
1465 var $availability = $( '<div>' );
1466 var $availabilityWeek = $( '<div>', { 'class': 'wpbc_appointment_services__availability_week' } ).appendTo( $availability );
1467 var hasWeeklyAvailability = false;
1468 var statusTemplate = catalogTemplate( 'wpbc-appointment-service-status-label' );
1469 var providerTemplate = catalogTemplate( 'wpbc-appointment-service-provider-labels' );
1470 var $actions = $( '<div>', { 'class': 'wpbc_appointment_services__actions' } );
1471 if ( row_schema && inline_field_template ) {
1472 $.each( row_schema.fields || [], function ( field_index, field ) {
1473 var column_id = String( field.column || '' );
1474 var field_key = String( field.key || '' );
1475 var field_data;
1476 if ( ! column_id || ! field_key || ! Object.prototype.hasOwnProperty.call( draft, field_key ) ) { return; }
1477 field_data = $.extend( {}, field, { original_value: field.value, value: draft[ field_key ] } );
1478 inline_cells[ column_id ] = ( inline_cells[ column_id ] || '' ) + inline_field_template( { field: field_data, service_id: id } );
1479 } );
1480 }
1481
1482 serviceThumbnailNode( service ).prependTo( $identity );
1483 if ( draft && inline_cells.service ) {
1484 $copy.append( inline_cells.service );
1485 } else {
1486 $( '<strong>', { 'class': 'wpbc_ui_listing__item_title wpbc_ui_listing__overflow_tooltip', 'data-wpbc-ui-catalog-overflow-tooltip': title, title: title, text: title } ).appendTo( $copy );
1487 $( '<span>', { 'class': 'wpbc_ui_listing__item_description wpbc_ui_listing__overflow_tooltip', 'data-wpbc-ui-catalog-overflow-tooltip': description, title: description, text: description } ).appendTo( $copy );
1488 }
1489 $.each( weekdayKeys, function ( dayIndex, day ) {
1490 var availableProviders = providers_available_on( service, day );
1491 var available = availableProviders.length > 0;
1492 var dayTitle = config.weekdays && config.weekdays[ dayIndex ] ? config.weekdays[ dayIndex ] : day;
1493 var providerTitles = $.map( availableProviders, function ( provider ) { return provider.title || ''; } ).filter( function ( providerTitle ) { return !! providerTitle; } );
1494 var availabilityTitle = available
1495 ? String( config.i18n.available_providers || 'Available Providers: %s' ).replace( '%s', providerTitles.join( ', ' ) )
1496 : ( config.i18n.no_available_providers || 'No assigned Providers are available' );
1497 if ( available ) { hasWeeklyAvailability = true; }
1498 $( '<span>', { 'class': 'wpbc_appointment_services__availability' + ( available ? ' is-available' : '' ), title: dayTitle + ': ' + availabilityTitle, 'aria-label': dayTitle + ': ' + availabilityTitle } ).appendTo( $availabilityWeek );
1499 } );
1500 $availability.append( availability_edit_links( service ) );
1501 if ( service.resource_ids && service.resource_ids.length && ! hasWeeklyAvailability ) {
1502 $( '<span>', { 'class': 'wpbc_appointment_services__availability_empty', text: config.i18n.no_availability || 'No weekly availability' } ).appendTo( $availability );
1503 }
1504 $( '<button>', { type: 'button', 'class': 'button-link wpbc_appointment_services__row_edit wpbc_icn_edit', 'data-service-id': id, title: config.i18n.edit || 'Edit Service', 'aria-label': config.i18n.edit || 'Edit Service' } ).appendTo( $actions );
1505 if ( service.actions && service.actions.archive ) {
1506 $( '<button>', { type: 'button', 'class': 'button-link wpbc_appointment_services__row_archive wpbc_icn_open_in_browser wpbc_icn_rotate_180', 'data-service-id': id, title: config.i18n.archive || 'Archive Service', 'aria-label': config.i18n.archive || 'Archive Service' } ).appendTo( $actions );
1507 }
1508
1509 return {
1510 service: nodeHtml( $identity ),
1511 duration: draft && inline_cells.duration ? inline_cells.duration : nodeHtml( service_duration_node( service ) ),
1512 price: draft && inline_cells.price ? inline_cells.price : nodeHtml( $( '<span>', { text: formatCost( service.base_cost ) } ) ),
1513 providers: providerTemplate ? providerTemplate( { providers: service.providers || [], max_visible: 3, empty_label: config.i18n.no_provider || 'No Providers assigned', more_label: config.i18n.more_providers || 'more Providers' } ) : nodeHtml( providerNodes( service ) ),
1514 availability: nodeHtml( $availability ),
1515 status: ( statusTemplate ? statusTemplate( { status: status, label: statusLabel( status ) } ) : '' ) + nodeHtml( $( '<span>', { 'class': 'wpbc_appointment_services__id', text: ( config.i18n.column_id || 'ID' ) + ': ' + id } ) ),
1516 actions: nodeHtml( $actions )
1517 };
1518 }
1519 /**
1520 * Render a normalized catalog response through Service-owned WP templates.
1521 *
1522 * @param {Object} response Normalized shared-catalog response.
1523 * @return {void}
1524 */
1525 function renderCatalogResponse( response ) {
1526 if ( ! response ) { return; }
1527 var columns = responseColumns( response );
1528 var rowTemplate = catalogTemplate( 'wpbc-appointment-service-row' );
1529 var cardTemplate = catalogTemplate( 'wpbc-appointment-service-card' );
1530 var headerTemplate = catalogTemplate( 'wpbc-appointment-services-header' );
1531 var cardsHeaderTemplate = catalogTemplate( 'wpbc-appointment-services-cards-header' );
1532 var paginationTemplate = catalogTemplate( 'wpbc-appointment-services-pagination' );
1533 var isCards = 'cards' === response.display.template_pack;
1534 var $rowHost = $( '[data-wpbc-appointment-services-rows]' );
1535 var $cardHost = $( '[data-wpbc-appointment-services-cards]' );
1536 var pagination = response.pagination || {};
1537 var renderedItems = [];
1538
1539 destroy_service_thumbnail_tooltips();
1540 state.last_response = response;
1541 state.services = response.items || [];
1542 state.page = Number( pagination.page_number || 1 );
1543 state.page_size = Number( pagination.items_per_page || state.page_size );
1544 state.total_items = Number( pagination.total_items || 0 );
1545 state.total_pages = Number( pagination.total_pages || 0 );
1546 state.sort_by = String( response.sorting.sort_by || state.sort_by );
1547 state.sort_order = String( response.sorting.sort_order || state.sort_order );
1548
1549 if ( headerTemplate && $rowHost.length ) {
1550 $( '[data-wpbc-appointment-services-header]' ).html( headerTemplate( { columns: columns, id_label: config.i18n.column_id || 'ID', sort_by: state.sort_by, sort_order: state.sort_order, select_all_label: config.i18n.select_all } ) );
1551 }
1552 if ( cardsHeaderTemplate && $cardHost.length ) {
1553 $( '[data-wpbc-appointment-services-cards-header]' ).html( cardsHeaderTemplate( { columns: responseSortColumns( response ), i18n: config.catalog.i18n || {}, select_all_label: config.i18n.select_all } ) );
1554 }
1555 $.each( state.services, function ( index, service ) {
1556 var template = isCards ? cardTemplate : rowTemplate;
1557 if ( template ) {
1558 var service_id = Number( service.service_id || service.id || 0 );
1559 renderedItems.push( template( {
1560 service_id: service_id,
1561 is_inspector_selected: service_id === state.selectedId,
1562 select_label: String( config.i18n.select_service || 'Select %s' ).replace( '%s', service.title || config.i18n.untitled ),
1563 picture_url: String( service.picture_url || '' ),
1564 columns: columns,
1565 cells: serviceCells( service )
1566 } ) );
1567 }
1568 } );
1569 ( isCards ? $cardHost : $rowHost ).html( renderedItems.join( '' ) );
1570 if ( paginationTemplate ) {
1571 $( '[data-wpbc-appointment-services-pagination]' ).html( paginationTemplate( {
1572 results_status: showingText( pagination.items_from || 0, pagination.items_to || 0, pagination.total_items || 0 ),
1573 show_label: config.catalog.i18n.show_label,
1574 per_page_label: config.catalog.i18n.per_page_label,
1575 items_per_page_options: config.catalog.items_per_page.options || [],
1576 items_per_page: state.page_size,
1577 aria_label: config.catalog.i18n.pagination_label,
1578 page_number_label: config.catalog.i18n.page_number,
1579 page_number: state.page,
1580 total_pages: Math.max( 1, state.total_pages ),
1581 previous_page: Math.max( 1, state.page - 1 ),
1582 next_page: Math.min( Math.max( 1, state.total_pages ), state.page + 1 ),
1583 previous_label: config.catalog.i18n.previous_page,
1584 next_label: config.catalog.i18n.next_page,
1585 has_previous: state.page > 1,
1586 has_next: state.total_pages > 0 && state.page < state.total_pages
1587 } ) );
1588 }
1589 refresh_service_thumbnail_tooltips();
1590 if ( catalogController ) { catalogController.refresh_controls(); catalogController.sync_table_min_width(); }
1591 var selection = get_selection_controller();
1592 if ( selection && 'function' === typeof selection.synchronize ) {
1593 selection.synchronize();
1594 }
1595 apply_pending_highlights();
1596 if ( state.inline_editing ) { render_inline_bar(); }
1597 }
1598 /** Load one Service and open it in the right inspector. */
1599 function loadOne( serviceId, focus_target ) {
1600 var request_sequence;
1601
1602 if ( ! serviceId || state.busy ) { return; }
1603 if ( editorIsOpen() && state.selectedId === serviceId ) { expand_service_inspector(); focus_requested_service_section(); return; }
1604 if ( ! can_replace_editor() ) { return; }
1605 state.inspector_focus_target = focus_target || document.activeElement;
1606 request_sequence = ++state.editor_request_sequence;
1607 setBusy( true );
1608 request( config.actions.load, { service_id: serviceId } ).done( function ( response ) {
1609 if ( request_sequence !== state.editor_request_sequence ) { return; }
1610 if ( response && response.success && response.data && response.data.service ) { fillEditor( response.data.service ); updateUrl( state.selectedId ); expand_service_inspector(); focus_requested_service_section(); return; }
1611 notify( messageFrom( response, config.i18n.load_failed ), 'error' );
1612 } ).fail( function ( xhr ) {
1613 if ( request_sequence === state.editor_request_sequence ) { notify( messageFrom( xhr.responseJSON, config.i18n.load_failed ), 'error' ); }
1614 } ).always( function () { setBusy( false ); } );
1615 }
1616 /**
1617 * Reload Services and Provider presentation data for the active filters.
1618 *
1619 * @param {boolean} save_preferences Whether to persist the active Service filters.
1620 * @return {void}
1621 */
1622 function loadList( save_preferences ) {
1623 var requestData = {
1624 search: $( '#wpbc_service_search' ).val() || '',
1625 status: state.status,
1626 resource_id: $( '#wpbc_service_provider_filter' ).val() || 0,
1627 page_number: 1
1628 };
1629 if ( save_preferences ) { requestData.preference_action = 'save'; }
1630 return catalogController ? catalogController.load( requestData ) : Promise.resolve( false );
1631 }
1632 /**
1633 * Keep the Service search clear control synchronized with the search value.
1634 *
1635 * The Service filters live outside the shared catalog mount, so this small
1636 * adapter mirrors the shared catalog clear-button visibility contract.
1637 *
1638 * @return {void}
1639 */
1640 function sync_search_clear_button() {
1641 var search_value = String( $( '#wpbc_service_search' ).val() || '' );
1642
1643 $( '[data-wpbc-appointment-services-search-clear]' ).prop( 'hidden', ! search_value );
1644 }
1645 /** Archive one Service after confirmation, then refresh the list. */
1646 function archiveService( serviceId ) {
1647 if ( ! serviceId || state.busy || ! w.confirm( config.i18n.confirm_archive || 'Archive this Service?' ) ) { return; }
1648 state.mutation_in_progress = true;
1649 setBusy( true );
1650 request( config.actions.archive, { service_id: serviceId } ).done( function ( response ) {
1651 if ( response && response.success ) {
1652 if ( state.selectedId === serviceId ) { state.selectedId = 0; setFieldsEnabled( false ); updateUrl( 0 ); }
1653 notify( response.data.message, 'success' ); loadList(); return;
1654 }
1655 notify( messageFrom( response, config.i18n.archive_failed ), 'error' );
1656 } ).fail( function ( xhr ) { notify( messageFrom( xhr.responseJSON, config.i18n.archive_failed ), 'error' ); } ).always( function () { state.mutation_in_progress = false; setBusy( false ); } );
1657 }
1658
1659 $( document ).on( 'click', '.wpbc_appointment_services__rightbar_tabs [role="tab"]', function ( event ) { event.preventDefault(); switchRightPanel( $( this ) ); } );
1660 $( document ).on( 'click', '.wpbc_appointment_services__rightbar .wpbc_ui__collapsible_group > .group__header', function ( event ) { event.preventDefault(); toggleInspectorGroup( $( this ) ); } );
1661 $( document ).on( 'click', '.wpbc_appointment_services__item', function ( event ) { if ( ! state.inline_editing && ! $( event.target ).closest( 'button, a, input, select, textarea, label' ).length ) { loadOne( Number( $( this ).data( 'service-id' ) || 0 ), this ); } } );
1662 $( document ).on( 'keydown', '.wpbc_appointment_services__item', function ( event ) { if ( ! state.inline_editing && ! $( event.target ).closest( 'button, a, input, select, textarea, label' ).length && ( 'Enter' === event.key || ' ' === event.key ) ) { event.preventDefault(); loadOne( Number( $( this ).data( 'service-id' ) || 0 ), this ); } } );
1663 $( document ).on( 'click', '.wpbc_appointment_services__row_edit', function () { loadOne( Number( $( this ).data( 'service-id' ) || 0 ), this ); } );
1664 $( document ).on( 'click', '.wpbc_appointment_services__row_archive', function () { archiveService( Number( $( this ).data( 'service-id' ) || 0 ) ); } );
1665 $( document ).on( 'click', '.wpbc_appointment_services__status_filter', function () {
1666 state.status = String( $( this ).data( 'service-status' ) || 'all' );
1667 state.page = 1;
1668 $( '.wpbc_appointment_services__status_filter' ).removeClass( 'is-active' ).attr( 'aria-pressed', 'false' );
1669 $( this ).addClass( 'is-active' ).attr( 'aria-pressed', 'true' );
1670 loadList( true );
1671 } );
1672 $( document ).on( 'click', '.wpbc_appointment_services__add', function () {
1673 if ( ! state.storageReady || state.busy || ! can_replace_editor() ) { return; }
1674 state.inspector_focus_target = this;
1675 fillEditor( blankService() );
1676 updateUrl( 0 );
1677 open_add_service_inspector();
1678 } );
1679 $( document ).on( 'click', '[data-wpbc-appointment-services-cancel]', function ( event ) {
1680 event.preventDefault();
1681 event.stopPropagation();
1682 close_service_inspector( true, true );
1683 } );
1684 $( document ).on( 'click', '.wpbc_appointment_services__save', function () {
1685 if ( ! state.storageReady || state.busy ) { return; }
1686 state.mutation_in_progress = true;
1687 setBusy( true );
1688 request( config.actions.save, { service: collectEditor() } ).done( function ( response ) {
1689 if ( response && response.success && response.data && response.data.service ) { fillEditor( response.data.service ); updateUrl( state.selectedId ); notify( response.data.message, 'success' ); loadList(); return; }
1690 notify( messageFrom( response, config.i18n.save_failed ), 'error' );
1691 } ).fail( function ( xhr ) { notify( messageFrom( xhr.responseJSON, config.i18n.save_failed ), 'error' ); } ).always( function () { state.mutation_in_progress = false; setBusy( false ); } );
1692 } );
1693 $( document ).on( 'click', '.wpbc_appointment_services__duplicate', function () {
1694 if ( ! state.selectedId || state.busy ) { return; }
1695 state.mutation_in_progress = true;
1696 setBusy( true );
1697 request( config.actions.duplicate, { service_id: state.selectedId } ).done( function ( response ) {
1698 if ( response && response.success && response.data && response.data.service ) { fillEditor( response.data.service ); updateUrl( state.selectedId ); notify( response.data.message, 'success' ); loadList(); return; }
1699 notify( messageFrom( response, config.i18n.duplicate_failed ), 'error' );
1700 } ).fail( function ( xhr ) { notify( messageFrom( xhr.responseJSON, config.i18n.duplicate_failed ), 'error' ); } ).always( function () { state.mutation_in_progress = false; setBusy( false ); } );
1701 } );
1702 $( document ).on( 'click', '.wpbc_appointment_services__archive', function () { archiveService( state.selectedId ); } );
1703 $( document ).on( 'click', '.wpbc_appointment_services__inline_toggle', function () { start_inline_editing(); } );
1704 $( document ).on( 'click', '[data-wpbc-appointment-services-inline-bar] [data-wpbc-ui-catalog-inline-cancel]', function ( event ) {
1705 event.preventDefault();
1706 event.stopPropagation();
1707 cancel_inline_editing( true );
1708 } );
1709 $( document ).on( 'click', '.wpbc_appointment_services__inline_review', function () {
1710 var changes = collect_inline_changes();
1711
1712 preview_operation( 'inline', $.map( Object.keys( changes ), function ( service_id ) { return Number( service_id ); } ), changes, this );
1713 } );
1714 $( document ).on( 'input change', '[data-wpbc-appointment-services-inline-field]', function () {
1715 var service_id = String( Number( $( this ).data( 'service-id' ) || 0 ) );
1716 var field_id = String( $( this ).data( 'wpbc-appointment-services-inline-field' ) || '' );
1717 var row_element;
1718 var indicator_element;
1719 var changed;
1720
1721 if ( state.inline_drafts[ service_id ] && field_id ) {
1722 state.inline_drafts[ service_id ][ field_id ] = $( this ).val();
1723 row_element = $( this ).closest( '.wpbc_appointment_services__item' ).get( 0 );
1724 indicator_element = row_element ? row_element.querySelector( '.wpbc_appointment_services__inline_identity_fields' ) : null;
1725 changed = inline_draft_changed( find_inline_schema( service_id ), state.inline_drafts[ service_id ] );
1726 if ( inlineWorkflowController ) {
1727 inlineWorkflowController.set_row_changed( row_element, changed, indicator_element, config.i18n.changed );
1728 }
1729 synchronize_inline_bar();
1730 }
1731 } );
1732 $( document ).on( 'click', '.wpbc_appointment_services__bulk_edit', function () { open_bulk_edit( this ); } );
1733 $( document ).on( 'click', '.wpbc_appointment_services__bulk_delete', function () { preview_deletion( get_selected_service_ids(), this ); } );
1734 $( document ).on( 'change', '[data-wpbc-appointment-services-bulk-enable]', function () {
1735 var field_id = String( $( this ).data( 'wpbc-appointment-services-bulk-enable' ) || '' );
1736 $( '[data-wpbc-appointment-services-bulk-value="' + field_id + '"], [data-wpbc-appointment-services-bulk-range="' + field_id + '"]' ).prop( 'disabled', ! this.checked );
1737 updateControls();
1738 } );
1739 $( document ).on( 'input change', '[data-wpbc-appointment-services-bulk-value]', function () {
1740 var field_id = String( $( this ).data( 'wpbc-appointment-services-bulk-value' ) || '' );
1741
1742 $( '[data-wpbc-appointment-services-bulk-range="' + field_id + '"]' ).val( $( this ).val() );
1743 updateControls();
1744 } );
1745 $( document ).on( 'input change', '[data-wpbc-appointment-services-bulk-range]', function () {
1746 var field_id = String( $( this ).data( 'wpbc-appointment-services-bulk-range' ) || '' );
1747
1748 $( '[data-wpbc-appointment-services-bulk-value="' + field_id + '"]' ).val( $( this ).val() );
1749 updateControls();
1750 } );
1751 $( document ).on( 'click', '.wpbc_appointment_services__operation_review', function () {
1752 preview_operation( 'bulk', get_selected_service_ids(), collect_bulk_changes(), this );
1753 } );
1754 $( document ).on( 'click', '.wpbc_appointment_services__operation_apply', apply_operation );
1755 $( document ).on( 'submit', '[data-wpbc-ui-catalog-inline-review-form], [data-wpbc-ui-catalog-delete-review-form]', function ( event ) {
1756 event.preventDefault();
1757 apply_operation();
1758 } );
1759 $( document ).on( 'change', '[data-wpbc-ui-catalog-delete-acknowledgement]', function ( event ) {
1760 if ( get_delete_review_workflow() ) { get_delete_review_workflow().handle_change( event ); }
1761 } );
1762 $( document ).on( 'input change', '[data-service-range-field]', function () {
1763 var field_id = String( $( this ).data( 'service-range-field' ) || '' );
1764 if ( field_id ) { $( '[data-service-field="' + field_id + '"]' ).val( $( this ).val() ).trigger( 'input' ); }
1765 } );
1766 $( document ).on( 'input change', 'input[type="number"][data-service-field]', function () { sync_numeric_range( String( $( this ).data( 'service-field' ) || '' ) ); } );
1767 $( document ).on( 'change', '[data-service-status-choice]', function () {
1768 if ( this.checked ) { $( '[data-service-field="status"]' ).val( this.value ).trigger( 'change' ); }
1769 } );
1770 $( document ).on( 'input change wpbc_media_upload_url_set', '[data-service-field="picture_url"]', function () { updateMediaPreview(); updateControls(); } );
1771 $( document ).on( 'click', '.wpbc_appointment_services__remove_image', function () {
1772 if ( $( this ).prop( 'disabled' ) ) { return; }
1773 $( '[data-service-field="picture_url"]' ).val( '' ).trigger( 'input' ).trigger( 'change' );
1774 } );
1775 $( document ).on( 'input', '#wpbc_service_search', function () {
1776 w.clearTimeout( searchTimer );
1777 state.page = 1;
1778 sync_search_clear_button();
1779 searchTimer = w.setTimeout( loadList, 250 );
1780 } );
1781 $( document ).on( 'click', '[data-wpbc-appointment-services-search-clear]', function ( event ) {
1782 var $search_control = $( '#wpbc_service_search' );
1783
1784 event.preventDefault();
1785 w.clearTimeout( searchTimer );
1786 $search_control.val( '' ).trigger( 'focus' );
1787 state.page = 1;
1788 sync_search_clear_button();
1789 loadList();
1790 } );
1791 $( document ).on( 'change', '#wpbc_service_provider_filter', function () { state.page = 1; loadList( true ); } );
1792 $( document ).on( 'wpbc:ui-catalog-loading', '#wpbc_appointment_services_catalog', function ( event ) {
1793 var event_detail = event.originalEvent && event.originalEvent.detail ? event.originalEvent.detail : {};
1794
1795 if ( event_detail.catalog_id !== 'appointment_services_catalog' ) { return; }
1796 state.catalog_loading = true;
1797 updateControls();
1798 } );
1799 $( document ).on( 'wpbc:ui-catalog-rendered', '#wpbc_appointment_services_catalog', function ( event ) {
1800 var response = event.originalEvent && event.originalEvent.detail ? event.originalEvent.detail.response : null;
1801 var filters = response && response.filters ? response.filters : {};
1802 if ( ! response || response.catalog_id !== 'appointment_services_catalog' ) { return; }
1803 state.catalog_loading = false;
1804 state.storageReady = !! filters.storage_ready;
1805 state.status = String( filters.status || state.status );
1806 indexProviders( filters.providers || ( response.items && response.items.length ? response.items[0].providers || [] : [] ) );
1807 updateSummary( filters.status_counts || {}, filters.provider_count || 0 );
1808 $( '.wpbc_appointment_services__status_filter' ).removeClass( 'is-active' ).attr( 'aria-pressed', 'false' )
1809 .filter( '[data-service-status="' + state.status + '"]' ).addClass( 'is-active' ).attr( 'aria-pressed', 'true' );
1810 $( '#wpbc_service_provider_filter' ).val( String( filters.resource_id || 0 ) );
1811 renderCatalogResponse( response );
1812 updateControls();
1813 if ( state.initial_selection_pending && state.selectedId ) {
1814 state.initial_selection_pending = false;
1815 loadOne( state.selectedId );
1816 }
1817 } );
1818 $( function () {
1819 var mount_element = document.getElementById( 'wpbc_appointment_services_catalog' );
1820 var page_element = document.querySelector( '.wpbc_appointment_services_page' );
1821 var protected_events_root = page_element || mount_element;
1822
1823 if ( ! $( '[data-wpbc-appointment-services-page="1"]' ).length || ! mount_element || ! config.catalog || ! w.wpbc_ui_catalog ) { return; }
1824 state.status = String( config.catalog.initial_request && config.catalog.initial_request.status ? config.catalog.initial_request.status : 'all' );
1825 $( '#wpbc_service_search' ).val( String( config.catalog.initial_request && config.catalog.initial_request.search ? config.catalog.initial_request.search : '' ) );
1826 sync_search_clear_button();
1827 protected_events_root.addEventListener( 'click', protect_inline_drafts_from_catalog_controls, true );
1828 protected_events_root.addEventListener( 'change', protect_inline_drafts_from_catalog_controls, true );
1829 protected_events_root.addEventListener( 'input', protect_inline_drafts_from_catalog_controls, true );
1830 catalogController = w.wpbc_ui_catalog.mount( config.catalog );
1831 if ( ! catalogController ) {
1832 notify( config.i18n.load_failed, 'error' );
1833 } else if ( 'function' === typeof w.wpbc_ui_catalog.create_inline_editing_workflow ) {
1834 inlineWorkflowController = w.wpbc_ui_catalog.create_inline_editing_workflow( mount_element, {
1835 controls_root: page_element || mount_element,
1836 page_element: page_element || mount_element,
1837 protected_selector: '.wpbc_appointment_services__status_filter, #wpbc_service_provider_filter, .wpbc_appointment_services__add'
1838 } );
1839 synchronize_inline_workflow();
1840 }
1841 $( '.wpbc_settings_page_wrapper' ).on( 'wpbc:right-sidebar-before-content-collapse.wpbcAppointmentServices', function ( event ) {
1842 if ( ( editorIsOpen() || state.operation_mode ) && ! close_service_inspector( true, false ) ) {
1843 event.preventDefault();
1844 }
1845 } );
1846 } );
1847 } )( window, jQuery );
1848