PluginProbe
Booking Calendar / 11.5
Booking Calendar v11.5
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.5, at includes/page-appointment-services/_src/appointment_services_page.js

814 lines 42.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 selectedId: Number( config.selected_id || 0 ),
7 requested_focus: String( config.focus_section || '' ),
8 focus_handled: false,
9 busy: false,
10 status: 'all',
11 services: [],
12 providers: {},
13 providerCount: 0,
14 editor_snapshot: '',
15 page: 1,
16 page_size: Number( config.listing && config.listing.items_per_page ? config.listing.items_per_page : 10 ),
17 total_items: 0,
18 total_pages: 0,
19 sort_by: String( config.listing && config.listing.sort_by ? config.listing.sort_by : 'service_id' ),
20 sort_order: String( config.listing && config.listing.sort_order ? config.listing.sort_order : 'desc' )
21 };
22 var searchTimer = 0;
23 var weekdayKeys = [ 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' ];
24
25 /** Extract an API message while preserving a caller-provided fallback. */
26 function messageFrom( response, fallback ) { return response && response.data && response.data.message ? response.data.message : fallback; }
27 /** Display a shared Booking Calendar administrator notice. */
28 function notify( message, type ) {
29 if ( message && typeof w.wpbc_admin_show_message === 'function' ) { w.wpbc_admin_show_message( message, type || 'info', 5000, false ); }
30 }
31 /**
32 * Show the native Booking Calendar Processing notice.
33 *
34 * The returned element identifies this specific request's notice so
35 * overlapping requests cannot dismiss each other's feedback.
36 *
37 * @return {jQuery} Processing notice wrapper, or an empty collection.
38 */
39 function show_processing_notice() {
40 if ( 'function' !== typeof w.wpbc_admin_show_message_processing ) {
41 return $();
42 }
43
44 w.wpbc_admin_show_message_processing( '' );
45
46 return $( '#ajax_working .wpbc_processing.wpbc_spin' ).last().closest( '[id^="wpbc_notice_"]' );
47 }
48 /**
49 * Hide the Processing notice created for one completed request.
50 *
51 * @param {jQuery} $processing_notice Notice wrapper returned by show_processing_notice().
52 * @return {void}
53 */
54 function hide_processing_notice( $processing_notice ) {
55 if ( $processing_notice && $processing_notice.length ) {
56 $processing_notice.stop( true, true ).hide();
57 }
58 }
59 /**
60 * Send an authenticated Appointment Services AJAX request.
61 *
62 * Every request uses the shared administrator Processing notice and removes
63 * only its own notice after the request settles.
64 *
65 * @param {string} action WordPress AJAX action name.
66 * @param {Object} data Request-specific payload.
67 * @return {jqXHR} jQuery AJAX promise for the request.
68 */
69 function request( action, data ) {
70 var $processing_notice = show_processing_notice();
71
72 return $.ajax( { url: config.ajax_url, type: 'POST', dataType: 'json', data: $.extend( { action: action, nonce: config.nonce }, data || {} ) } )
73 .always( function () { hide_processing_notice( $processing_notice ); } );
74 }
75 /** Toggle the loading overlay without hiding the existing Service table. */
76 function setLoading( isLoading ) {
77 $( '.wpbc_appointment_services__loading' ).toggleClass( 'is-visible', isLoading ).attr( 'aria-hidden', isLoading ? 'false' : 'true' );
78 $( '.wpbc_appointment_services__content' ).attr( 'aria-busy', isLoading ? 'true' : 'false' );
79 }
80 /** Activate the Settings or Help panel selected in the right sidebar. */
81 function switchRightPanel( $tab ) {
82 var panelId = $tab.attr( 'aria-controls' );
83 var panel = panelId ? document.getElementById( panelId ) : null;
84 var $tabs = $tab.closest( '.wpbc_appointment_services__rightbar_tabs' ).find( '[role="tab"]' );
85 var $panels = $( '.wpbc_appointment_services__rightbar [role="tabpanel"]' );
86 if ( ! panel ) { return; }
87 $tabs.attr( 'aria-selected', 'false' );
88 $tab.attr( 'aria-selected', 'true' );
89 $panels.prop( 'hidden', true ).attr( 'aria-hidden', 'true' );
90 $( panel ).prop( 'hidden', false ).attr( 'aria-hidden', 'false' );
91 updateControls();
92 }
93 /**
94 * Tell the active Setup Wizard bar that the page workspace changed width.
95 *
96 * The delayed notification runs after the shared sidebar transition so the
97 * setup bar can measure the final inspector boundary.
98 *
99 * @return {void}
100 */
101 function notify_setup_wizard_layout_changed() {
102 $( document ).trigger( 'wpbc_setup_wizard_layout_changed' );
103 w.setTimeout( function () { $( document ).trigger( 'wpbc_setup_wizard_layout_changed' ); }, 300 );
104 }
105 /**
106 * Expand the right sidebar and display the Service Settings inspector.
107 *
108 * @return {void}
109 */
110 function expand_service_inspector() {
111 var $settings_tab = $( '#wpbc_tab_service_settings' );
112
113 if ( 'function' === typeof w.wpbc_admin_ui__sidebar_right__do_max ) {
114 w.wpbc_admin_ui__sidebar_right__do_max();
115 }
116 if ( $settings_tab.length ) {
117 switchRightPanel( $settings_tab );
118 }
119 notify_setup_wizard_layout_changed();
120 }
121 /**
122 * Reveal, expand, highlight, and optionally focus one Service editor group.
123 *
124 * @param {string} fields_selector Group fields selector from the fixed editor markup.
125 * @param {string} focus_selector Optional control selector to focus after scrolling.
126 * @return {boolean} True when the requested inspector group was found.
127 */
128 function open_service_inspector_group( fields_selector, focus_selector ) {
129 var $group_fields = $( fields_selector );
130 var $group = $group_fields.closest( '.wpbc_ui__collapsible_group' );
131 var $group_header = $group.children( '.group__header' );
132 var group_element = $group.get( 0 );
133 var focus_element = focus_selector ? document.querySelector( focus_selector ) : null;
134
135 expand_service_inspector();
136 if ( ! $group.length ) {
137 return false;
138 }
139
140 $group.addClass( 'is-open' );
141 $group_header.attr( 'aria-expanded', 'true' );
142 $group_fields.prop( 'hidden', false ).attr( 'aria-hidden', 'false' );
143 $group.removeClass( 'wpbc_appointment_services__focus_pulse' );
144 if ( group_element ) {
145 void group_element.offsetWidth;
146 $group.addClass( 'wpbc_appointment_services__focus_pulse' );
147 try { group_element.scrollIntoView( { behavior: 'smooth', block: 'start', inline: 'nearest' } ); }
148 catch ( error ) { group_element.scrollIntoView( true ); }
149 w.setTimeout( function () { $group.removeClass( 'wpbc_appointment_services__focus_pulse' ); }, 900 );
150 }
151 if ( focus_element && typeof focus_element.focus === 'function' ) {
152 try { focus_element.focus( { preventScroll: true } ); }
153 catch ( error ) { focus_element.focus(); }
154 }
155
156 return true;
157 }
158 /**
159 * Open, reveal, and highlight the General Service editor section.
160 *
161 * @return {void}
162 */
163 function open_add_service_inspector() {
164 open_service_inspector_group( '#wpbc_service_general', '[data-service-field="title"]' );
165 }
166 /**
167 * Apply the one-time inspector focus requested by an administration link.
168 *
169 * @return {void}
170 */
171 function focus_requested_service_section() {
172 if ( state.focus_handled || 'booking_form' !== state.requested_focus ) {
173 return;
174 }
175
176 if ( open_service_inspector_group( '#wpbc_service_form', '[data-service-field="booking_form_id"]' ) ) {
177 state.focus_handled = true;
178 if ( w.history && w.URL ) {
179 var url = new w.URL( w.location.href );
180 url.searchParams.delete( 'wpbc_service_focus' );
181 w.history.replaceState( {}, '', url.toString() );
182 }
183 }
184 }
185 /** Expand or collapse one inspector field group. */
186 function toggleInspectorGroup( $button ) {
187 var $group = $button.closest( '.wpbc_ui__collapsible_group' );
188 var $fields = $group.find( '> .group__fields' );
189 var isOpen = $group.hasClass( 'is-open' );
190 $group.toggleClass( 'is-open', ! isOpen );
191 $button.attr( 'aria-expanded', isOpen ? 'false' : 'true' );
192 $fields.prop( 'hidden', isOpen ).attr( 'aria-hidden', isOpen ? 'true' : 'false' );
193 }
194 /** Determine whether a Service is currently loaded into the editor. */
195 function editorIsOpen() { return ! $( '[data-service-field="title"]' ).prop( 'disabled' ); }
196 /**
197 * Synchronize one numeric Service field with its range control.
198 *
199 * The slider expands to represent an existing value above its normal visual
200 * range without clamping or rewriting that stored value.
201 *
202 * @param {string} field_id Service field identifier.
203 * @return {void}
204 */
205 function sync_numeric_range( field_id ) {
206 var $field = $( '[data-service-field="' + field_id + '"]' );
207 var $range = $( '[data-service-range-field="' + field_id + '"]' );
208 var value = Number( $field.val() );
209 var default_min = Number( $range.data( 'service-range-default-min' ) );
210 var default_max = Number( $range.data( 'service-range-default-max' ) );
211 var step = Number( $range.attr( 'step' ) || 1 );
212 var range_max;
213
214 if ( ! $field.length || ! $range.length || ! isFinite( value ) ) { return; }
215 default_min = isFinite( default_min ) ? default_min : 0;
216 default_max = isFinite( default_max ) ? default_max : 100;
217 step = isFinite( step ) && step > 0 ? step : 1;
218 range_max = value > default_max ? default_min + ( Math.ceil( ( value - default_min ) / step ) * step ) : default_max;
219 $range.attr( { min: default_min, max: range_max } ).val( value );
220 }
221 /**
222 * Synchronize every numeric Service field with its range control.
223 *
224 * @return {void}
225 */
226 function sync_all_numeric_ranges() {
227 $( '[data-service-range-field]' ).each( function () { sync_numeric_range( String( $( this ).data( 'service-range-field' ) || '' ) ); } );
228 }
229 /**
230 * Synchronize the visible Status radios with the stored Service status field.
231 *
232 * @return {void}
233 */
234 function sync_status_radios() {
235 var status = String( $( '[data-service-field="status"]' ).val() || 'active' );
236 $( '[data-service-status-choice]' ).each( function () {
237 $( this ).prop( 'checked', String( $( this ).val() ) === status );
238 } );
239 }
240 /**
241 * Synchronize the Service image preview with the readonly URL field.
242 *
243 * @return {void}
244 */
245 function updateMediaPreview() {
246 var pictureUrl = String( $( '[data-service-field="picture_url"]' ).val() || '' ).trim();
247 var $image = $( '.wpbc_appointment_services__media_image' );
248 if ( pictureUrl ) { $image.attr( 'src', pictureUrl ); } else { $image.removeAttr( 'src' ); }
249 $image.prop( 'hidden', ! pictureUrl );
250 $( '.wpbc_appointment_services__media_placeholder' ).prop( 'hidden', !! pictureUrl );
251 }
252 /** Synchronize toolbar and inspector action states with the page state. */
253 function updateControls() {
254 var open = state.storageReady && editorIsOpen();
255 var hasPicture = !! String( $( '[data-service-field="picture_url"]' ).val() || '' ).trim();
256 var show_save = open && 'true' === $( '#wpbc_tab_service_settings' ).attr( 'aria-selected' );
257 $( '.wpbc_appointment_services__add' ).prop( 'hidden', false ).prop( 'disabled', ! state.storageReady || state.busy );
258 $( '.wpbc_appointment_services__right_sidebar_footer, .wpbc_appointment_services__top_actions' ).prop( 'hidden', ! show_save );
259 $( '.wpbc_appointment_services__save' ).prop( 'hidden', ! show_save ).prop( 'disabled', ! open || state.busy );
260 $( '.wpbc_appointment_services__duplicate, .wpbc_appointment_services__archive' ).prop( 'disabled', ! open || ! state.selectedId || state.busy );
261 $( '.wpbc_appointment_services__media_preview, .wpbc_appointment_services__select_image' ).prop( 'disabled', ! open || state.busy );
262 $( '.wpbc_appointment_services__remove_image' ).prop( 'disabled', ! open || ! hasPicture || state.busy );
263 }
264 /** Mark the page busy during a mutating request and refresh controls. */
265 function setBusy( value ) { state.busy = value; $( '.wpbc_appointment_services_page' ).toggleClass( 'is-busy', value ); updateControls(); }
266 /** Enable or disable all fields in the Service inspector. */
267 function setFieldsEnabled( enabled ) { $( '[data-service-field], [data-service-range-field], [data-service-status-choice]' ).prop( 'disabled', ! enabled ); updateControls(); }
268 /** Return defaults for a new unsaved Service. */
269 function blankService() {
270 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: [] };
271 }
272 /** Populate the inspector from a normalized Service response. */
273 function fillEditor( service ) {
274 service = $.extend( blankService(), service || {} );
275 state.selectedId = Number( service.service_id || 0 );
276 $.each( service, function ( key, value ) { $( '[data-service-field="' + key + '"]' ).val( value ); } );
277 sync_status_radios();
278 sync_all_numeric_ranges();
279 updateMediaPreview();
280 setFieldsEnabled( state.storageReady );
281 $( '.wpbc_appointment_services__item' ).removeClass( 'is-selected' ).attr( 'aria-current', 'false' );
282 $( '.wpbc_appointment_services__item[data-service-id="' + state.selectedId + '"]' ).addClass( 'is-selected' ).attr( 'aria-current', 'true' );
283 capture_editor_snapshot();
284 }
285 /** Collect the current Service inspector values for saving. */
286 function collectEditor() {
287 var service = { service_id: state.selectedId };
288 $( '[data-service-field]' ).each( function () { service[ $( this ).data( 'service-field' ) ] = $( this ).val(); } );
289 return service;
290 }
291 /**
292 * Store the current editor values as the last loaded or saved state.
293 *
294 * @return {void}
295 */
296 function capture_editor_snapshot() {
297 state.editor_snapshot = JSON.stringify( collectEditor() );
298 }
299 /**
300 * Determine whether the open Service editor contains unsaved changes.
301 *
302 * @return {boolean} True when current fields differ from the captured state.
303 */
304 function is_editor_dirty() {
305 return editorIsOpen() && state.editor_snapshot !== JSON.stringify( collectEditor() );
306 }
307 /**
308 * Confirm before replacing an editor that contains unsaved Service changes.
309 *
310 * @return {boolean} True when replacing the current editor may continue.
311 */
312 function can_replace_editor() {
313 return ! is_editor_dirty() || w.confirm( config.i18n.confirm_discard || 'Discard unsaved Service changes?' );
314 }
315 /** Reflect the selected Service in the admin URL without reloading. */
316 function updateUrl( serviceId ) {
317 if ( ! w.history || ! w.URL ) { return; }
318 var url = new w.URL( w.location.href );
319 if ( serviceId ) { url.searchParams.set( 'service_id', serviceId ); } else { url.searchParams.delete( 'service_id' ); }
320 w.history.replaceState( {}, '', url.toString() );
321 }
322 /** Render the empty or storage-unavailable state in the central workspace. */
323 function renderEmpty( message, storageNotice ) {
324 var $empty = $( '.wpbc_appointment_services__empty' );
325 var noProviders = ! storageNotice && ! message && 0 === state.providerCount;
326 $( '.wpbc_appointment_services__list' ).prop( 'hidden', true );
327 $empty.prop( 'hidden', false ).toggleClass( 'is-storage-notice', !! storageNotice );
328 $empty.find( 'h2' ).text( storageNotice ? ( config.i18n.not_connected || 'Services storage is not connected' ) : ( noProviders ? ( config.i18n.no_providers || 'No Providers available' ) : ( config.i18n.empty || 'No Services yet' ) ) );
329 $empty.find( 'p' ).text( message || ( noProviders ? config.i18n.no_providers_help : config.i18n.empty_help ) || '' );
330 }
331 /** Index Provider presentation records by booking resource ID. */
332 function indexProviders( providers ) {
333 state.providers = {};
334 $.each( providers || [], function ( index, provider ) { state.providers[ String( provider.id ) ] = provider; } );
335 }
336 /** Update status and Provider counters above the Service table. */
337 function updateSummary( counts, providerCount ) {
338 counts = $.extend( { all: 0, active: 0, inactive: 0, archived: 0 }, counts || {} );
339 state.providerCount = Number( providerCount || 0 );
340 $.each( counts, function ( status, count ) { $( '[data-service-count="' + status + '"]' ).text( Number( count || 0 ) ); } );
341 $( '[data-provider-count]' ).text( state.providerCount );
342 $( '.wpbc_appointment_services__provider_notice' ).prop( 'hidden', 0 !== state.providerCount );
343 }
344 /** Format a normalized Service cost using the configured currency symbol. */
345 function formatCost( cost ) {
346 var amount = Number( cost || 0 );
347 var symbol = config.currency_symbol || '$';
348 return symbol + amount.toFixed( 2 );
349 }
350 /** Build compact Provider avatar nodes for one Service row. */
351 function providerNodes( service ) {
352 var ids = $.map( service.resource_ids || [], function ( value ) { return Number( value || 0 ); } );
353 var $stack = $( '<div>', { 'class': 'wpbc_appointment_services__provider_stack' } );
354 if ( ! ids.length ) { return $( '<span>', { 'class': 'wpbc_appointment_services__no_provider', text: config.i18n.no_provider || 'No Providers assigned' } ); }
355 $.each( ids.slice( 0, 3 ), function ( index, id ) {
356 var provider = state.providers[ String( id ) ] || { id: id, title: 'Provider #' + id, initials: 'P', avatar_url: '' };
357 var has_availability = false !== provider.has_weekly_availability;
358 var provider_title = provider.title || 'Provider #' + id;
359 var avatar_title = provider_title;
360 var avatar_attributes;
361 var $avatar;
362
363 if ( ! has_availability ) { avatar_title += ' — ' + ( config.i18n.no_availability || 'No weekly availability' ); }
364 avatar_attributes = {
365 'class': 'wpbc_appointment_services__provider_avatar' + ( has_availability ? '' : ' has-no-availability' ),
366 title: avatar_title
367 };
368 if ( provider.availability_url ) {
369 avatar_title = String( config.i18n.edit_availability || 'Edit availability for %s' ).replace( '%s', provider_title );
370 avatar_attributes.href = provider.availability_url;
371 avatar_attributes.title = avatar_title;
372 avatar_attributes[ 'aria-label' ] = avatar_title;
373 $avatar = $( '<a>', avatar_attributes );
374 } else {
375 $avatar = $( '<span>', avatar_attributes );
376 }
377 if ( provider.avatar_url ) { $( '<img>', { src: provider.avatar_url, alt: '', loading: 'lazy' } ).appendTo( $avatar ); }
378 else { $avatar.text( provider.initials || 'P' ); }
379 $stack.append( $avatar );
380 } );
381 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 ); }
382 return $stack;
383 }
384 /**
385 * Build the Service thumbnail used in the management table.
386 *
387 * @param {Object} service Normalized Service response.
388 * @return {jQuery} Thumbnail wrapper containing an image or placeholder icon.
389 */
390 function serviceThumbnailNode( service ) {
391 var pictureUrl = String( service.picture_url || '' ).trim();
392 var service_title = String( service.title || config.i18n.untitled || 'Untitled Service' );
393 var service_description = String( service.description || '' ).trim() || config.i18n.no_description || 'No description';
394 var tooltip_format = String( config.i18n.service_thumbnail_tooltip || 'Title: %1$s\nDescription: %2$s' );
395 var tooltip_text = tooltip_format.replace( '%1$s', service_title ).replace( '%2$s', service_description );
396 var $thumbnail = $( '<span>', {
397 'class': 'wpbc_appointment_services__service_thumbnail tooltip_top',
398 'data-original-title': tooltip_text,
399 'aria-label': tooltip_text,
400 role: 'img',
401 tabindex: '0'
402 } );
403 if ( pictureUrl ) {
404 $( '<img>', { src: pictureUrl, alt: '', loading: 'lazy', decoding: 'async' } ).appendTo( $thumbnail );
405 } else {
406 $( '<i>', { 'class': 'menu_icon icon-1x wpbc-bi-image-fill', 'aria-hidden': 'true' } ).appendTo( $thumbnail );
407 }
408 return $thumbnail;
409 }
410 /**
411 * Destroy Service thumbnail tooltips before AJAX replaces their elements.
412 *
413 * @return {void}
414 */
415 function destroy_service_thumbnail_tooltips() {
416 $( '.wpbc_appointment_services__service_thumbnail' ).each( function () {
417 if ( this._tippy && 'function' === typeof this._tippy.destroy ) {
418 this._tippy.destroy();
419 }
420 } );
421 }
422 /**
423 * Initialize Service thumbnail tooltips after an AJAX listing render.
424 *
425 * The native title attribute is used only when the Booking Calendar Tippy
426 * helper is unavailable, avoiding duplicate browser and Tippy tooltips.
427 *
428 * @return {void}
429 */
430 function refresh_service_thumbnail_tooltips() {
431 var listing_selector = '#wpbc_ui_listing_appointment_services_catalog ';
432 var $thumbnails = $( listing_selector + '.wpbc_appointment_services__service_thumbnail' );
433 var tooltips_initialized = false;
434
435 if ( 'function' === typeof w.wpbc_define_tippy_tooltips ) {
436 tooltips_initialized = w.wpbc_define_tippy_tooltips( listing_selector );
437 }
438 if ( tooltips_initialized ) {
439 return;
440 }
441 $thumbnails.each( function () {
442 $( this ).attr( 'title', $( this ).attr( 'data-original-title' ) || '' );
443 } );
444 }
445 /**
446 * Build the compact duration and before/after buffer summary for one Service.
447 *
448 * @param {Object} service Normalized Service response.
449 * @return {jQuery} Duration details wrapper for the listing column.
450 */
451 function service_duration_node( service ) {
452 var duration_minutes = Math.max( 0, Number( service.duration_minutes || 0 ) );
453 var buffer_before_minutes = Math.max( 0, Number( service.buffer_before_minutes || 0 ) );
454 var buffer_after_minutes = Math.max( 0, Number( service.buffer_after_minutes || 0 ) );
455 var duration_format = String( config.i18n.duration_minutes || '%s min' );
456 var buffers_format = String( config.i18n.buffers_summary || 'Buffers: %1$s / %2$s min' );
457 var buffers_tooltip_format = String( config.i18n.buffers_tooltip || 'Buffer before: %1$s min; Buffer after: %2$s min' );
458 var buffers_summary = buffers_format.replace( '%1$s', buffer_before_minutes ).replace( '%2$s', buffer_after_minutes );
459 var buffers_tooltip = buffers_tooltip_format.replace( '%1$s', buffer_before_minutes ).replace( '%2$s', buffer_after_minutes );
460 var $duration_details = $( '<span>', { 'class': 'wpbc_appointment_services__duration_details' } );
461
462 $( '<strong>', {
463 'class': 'wpbc_appointment_services__duration_value',
464 text: duration_format.replace( '%s', duration_minutes )
465 } ).appendTo( $duration_details );
466 $( '<span>', {
467 'class': 'wpbc_appointment_services__buffers_summary',
468 text: buffers_summary,
469 title: buffers_tooltip,
470 'aria-label': buffers_tooltip
471 } ).appendTo( $duration_details );
472
473 return $duration_details;
474 }
475 /**
476 * Return assigned Providers with recurring availability on one weekday.
477 *
478 * @param {Object} service Normalized Service response.
479 * @param {string} day Weekday key from mon through sun.
480 * @return {Array<Object>} Matching Provider presentation records.
481 */
482 function providers_available_on( service, day ) {
483 var available_providers = [];
484 $.each( service.resource_ids || [], function ( index, id ) {
485 var provider = state.providers[ String( Number( id || 0 ) ) ];
486 if ( provider && provider.weekdays && provider.weekdays[ day ] ) {
487 available_providers.push( provider );
488 }
489 } );
490
491 return available_providers;
492 }
493 /**
494 * Build compact Provider-specific links below the weekly availability dots.
495 *
496 * @param {Object} service Normalized Service response.
497 * @return {jQuery} Availability links, or an empty collection when unavailable.
498 */
499 function availability_edit_links( service ) {
500 var $links = $( '<div>', {
501 'class': 'wpbc_appointment_services__availability_links',
502 'aria-label': config.i18n.edit_provider_availability || 'Edit Provider availability'
503 } );
504
505 $.each( service.resource_ids || [], function ( index, id ) {
506 var provider = state.providers[ String( Number( id || 0 ) ) ];
507 var provider_title;
508 var link_title;
509
510 if ( ! provider || ! provider.availability_url ) {
511 return;
512 }
513 provider_title = provider.title || 'Provider #' + Number( id || 0 );
514 link_title = String( config.i18n.edit_availability || 'Edit availability for %s' ).replace( '%s', provider_title );
515 $( '<a>', {
516 'class': 'wpbc_appointment_services__availability_link',
517 href: provider.availability_url,
518 text: provider.initials || 'P',
519 title: link_title,
520 'aria-label': link_title
521 } ).appendTo( $links );
522 } );
523
524 return $links.children().length ? $links : $();
525 }
526 /** Convert a stored Service status to its translated UI label. */
527 function statusLabel( status ) {
528 if ( 'inactive' === status ) { return config.i18n.draft || 'Draft'; }
529 if ( 'archived' === status ) { return config.i18n.archived || 'Archived'; }
530 return config.i18n.active || 'Active';
531 }
532 /** Format the translated table pagination summary. */
533 function showingText( from, to, total ) {
534 var format = config.i18n.showing || 'Showing %1$s–%2$s of %3$s Services';
535 return format.replace( '%1$s', from ).replace( '%2$s', to ).replace( '%3$s', total );
536 }
537 /**
538 * Apply the server-authoritative listing preference to state and controls.
539 *
540 * @param {Object} listing_settings Shared listing client settings.
541 * @return {void}
542 */
543 function sync_listing_settings( listing_settings ) {
544 var page_size = Number( listing_settings && listing_settings.items_per_page ? listing_settings.items_per_page : state.page_size );
545 if ( ! isFinite( page_size ) || page_size < 1 ) { return; }
546 state.page_size = page_size;
547 $( '[data-wpbc-listing-items-per-page-control="appointment_services_catalog"]' ).val( String( page_size ) );
548 }
549 /**
550 * Synchronize sortable table headers with the server-authoritative ordering.
551 *
552 * @param {Object} sorting Normalized sort key and direction.
553 * @return {void}
554 */
555 function sync_sorting_controls( sorting ) {
556 state.sort_by = String( sorting && sorting.sort_by ? sorting.sort_by : state.sort_by );
557 state.sort_order = 'desc' === String( sorting && sorting.sort_order ? sorting.sort_order : state.sort_order ) ? 'desc' : 'asc';
558
559 $( '[data-wpbc-listing-sort="appointment_services_catalog"]' ).each( function () {
560 var $sort_link = $( this );
561 var is_active = String( $sort_link.data( 'wpbc-listing-sort-key' ) || '' ) === state.sort_by;
562 var $sort_icon = $sort_link.find( '.wpbc_ui_listing__sort_icon' );
563
564 $sort_link.toggleClass( 'is-active', is_active );
565 $sort_icon.removeClass( 'wpbc_icn_import_export wpbc-bi-arrow-down wpbc-bi-arrow-up' )
566 .addClass( is_active ? ( 'desc' === state.sort_order ? 'wpbc-bi-arrow-down' : 'wpbc-bi-arrow-up' ) : 'wpbc_icn_import_export' );
567 } );
568 $( '.wpbc_appointment_services__table th' ).each( function () {
569 var $column = $( this );
570 if ( ! $column.find( '.wpbc_ui_listing__sort_link' ).length ) {
571 return;
572 }
573 var has_active_sort = $column.find( '.wpbc_ui_listing__sort_link.is-active' ).length > 0;
574 $column.attr( 'aria-sort', has_active_sort ? ( 'desc' === state.sort_order ? 'descending' : 'ascending' ) : 'none' );
575 } );
576 }
577 /**
578 * Synchronize direct-page and previous/next controls with the server response.
579 *
580 * @param {Object} pagination Server-authoritative pagination metadata.
581 * @return {void}
582 */
583 function sync_pagination_controls( pagination ) {
584 var total_pages = Math.max( 0, Number( pagination && pagination.total_pages ? pagination.total_pages : 0 ) );
585 var maximum_page = Math.max( 1, total_pages );
586 var $page_control = $( '[data-wpbc-listing-page-number-control="appointment_services_catalog"]' );
587 $page_control.attr( 'max', maximum_page ).val( state.page ).prop( 'disabled', total_pages <= 1 );
588 $( '[data-wpbc-listing-page-total="appointment_services_catalog"]' ).text( total_pages );
589 }
590 /**
591 * Request the valid page entered in the shared direct-page control.
592 *
593 * @param {jQuery} $page_control Direct-page number input.
594 * @return {void}
595 */
596 function request_selected_page( $page_control ) {
597 var requested_page = Number( $page_control.val() );
598 requested_page = Math.min( Math.max( 1, isFinite( requested_page ) ? requested_page : state.page ), Math.max( 1, state.total_pages ) );
599 $page_control.val( requested_page );
600 if ( requested_page === state.page ) { return; }
601 state.page = requested_page;
602 loadList();
603 }
604 /**
605 * Render one server-selected page of Services in the management table.
606 *
607 * @param {Array} services Services returned for the requested page only.
608 * @param {Object} pagination Server-authoritative pagination metadata.
609 * @return {void}
610 */
611 function renderList( services, pagination ) {
612 var $list = $( '.wpbc_appointment_services__list' );
613 var $tbody = $( '.wpbc_appointment_services__table tbody' );
614 var items_from;
615 var items_to;
616 var page_items;
617 destroy_service_thumbnail_tooltips();
618 $tbody.empty();
619 pagination = pagination || {};
620 state.services = services || [];
621 state.page = Math.max( 1, Number( pagination.page_number || state.page || 1 ) );
622 state.total_items = Math.max( 0, Number( pagination.total_items || 0 ) );
623 state.total_pages = Math.max( 0, Number( pagination.total_pages || 0 ) );
624 sync_pagination_controls( pagination );
625 $( '.wpbc_appointment_services__empty' ).prop( 'hidden', true );
626 if ( ! state.services.length ) { renderEmpty( '', false ); return; }
627 items_from = Math.max( 1, Number( pagination.items_from || ( ( state.page - 1 ) * state.page_size ) + 1 ) );
628 items_to = Math.max( items_from, Number( pagination.items_to || ( items_from + state.services.length - 1 ) ) );
629 page_items = state.services;
630 $list.prop( 'hidden', false );
631
632 $.each( page_items, function ( index, service ) {
633 var id = Number( service.service_id || 0 );
634 var title = service.title || config.i18n.untitled || 'Untitled Service';
635 var description = String( service.description || '#' + id );
636 var status = String( service.status || 'active' );
637 var $row = $( '<tr>', { 'class': 'wpbc_appointment_services__item', 'data-service-id': id, tabindex: '0', 'aria-current': id === state.selectedId ? 'true' : 'false' } );
638 var $serviceCell = $( '<td>', { 'class': 'column-service', 'data-label': config.i18n.column_service || 'Service' } ).appendTo( $row );
639 $row.toggleClass( 'is-selected', id === state.selectedId );
640 var $serviceIdentity = $( '<div>', { 'class': 'wpbc_appointment_services__service_identity' } ).appendTo( $serviceCell );
641 var $serviceCopy = $( '<span>', { 'class': 'wpbc_appointment_services__service_copy' } ).appendTo( $serviceIdentity );
642 serviceThumbnailNode( service ).prependTo( $serviceIdentity );
643 $( '<strong>', { text: title } ).appendTo( $serviceCopy );
644 $( '<span>', { text: description } ).appendTo( $serviceCopy );
645 $( '<td>', { 'class': 'column-duration', 'data-label': config.i18n.column_duration || 'Duration' } ).append( service_duration_node( service ) ).appendTo( $row );
646 if ( config.pricing_available ) {
647 $( '<td>', { 'class': 'column-price', 'data-label': config.i18n.column_price || 'Price', text: formatCost( service.base_cost ) } ).appendTo( $row );
648 }
649 $( '<td>', { 'class': 'column-providers', 'data-label': config.i18n.column_providers || 'Providers' } ).append( providerNodes( service ) ).appendTo( $row );
650 var $availabilityCell = $( '<td>', { 'class': 'column-weekdays', 'data-label': config.i18n.column_weekly_availability || 'Weekly Availability' } ).appendTo( $row );
651 var $availabilityWeek = $( '<div>', { 'class': 'wpbc_appointment_services__availability_week' } ).appendTo( $availabilityCell );
652 var hasWeeklyAvailability = false;
653 $.each( weekdayKeys, function ( dayIndex, day ) {
654 var available_providers = providers_available_on( service, day );
655 var available = available_providers.length > 0;
656 if ( available ) { hasWeeklyAvailability = true; }
657 var dayTitle = config.weekdays && config.weekdays[ dayIndex ] ? config.weekdays[ dayIndex ] : day;
658 var provider_titles = $.map( available_providers, function ( provider ) { return provider.title || ''; } ).filter( function ( title ) { return !! title; } );
659 var availability_title = available
660 ? String( config.i18n.available_providers || 'Available Providers: %s' ).replace( '%s', provider_titles.join( ', ' ) )
661 : ( config.i18n.no_available_providers || 'No assigned Providers are available' );
662 $( '<span>', { 'class': 'wpbc_appointment_services__availability' + ( available ? ' is-available' : '' ), title: dayTitle + ': ' + availability_title, 'aria-label': dayTitle + ': ' + availability_title } ).appendTo( $availabilityWeek );
663 } );
664 $availabilityCell.append( availability_edit_links( service ) );
665 if ( service.resource_ids && service.resource_ids.length && ! hasWeeklyAvailability ) {
666 $( '<span>', { 'class': 'wpbc_appointment_services__availability_empty', text: config.i18n.no_availability || 'No weekly availability' } ).appendTo( $availabilityCell );
667 }
668 var $status_cell = $( '<td>', { 'class': 'column-status', 'data-label': config.i18n.column_status || 'Status' } ).appendTo( $row );
669 var $status_identity = $( '<div>', { 'class': 'wpbc_appointment_services__status_identity' } ).appendTo( $status_cell );
670 $( '<span>', { 'class': 'wpbc_appointment_services__status status-' + status, text: statusLabel( status ) } ).appendTo( $status_identity );
671 $( '<span>', { 'class': 'wpbc_appointment_services__id', text: ( config.i18n.column_id || 'ID' ) + ': ' + id } ).appendTo( $status_identity );
672 var $actions = $( '<td>', { 'class': 'column-actions', 'data-label': config.i18n.column_actions || 'Actions' } ).appendTo( $row );
673 $( '<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 );
674 if ( 'archived' !== status ) { $( '<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 ); }
675 $tbody.append( $row );
676 } );
677
678 refresh_service_thumbnail_tooltips();
679 $( '.wpbc_appointment_services__list_footer' ).prop( 'hidden', false );
680 $( '.wpbc_appointment_services__result_count' ).text( showingText( items_from, items_to, state.total_items ) );
681 $( '.wpbc_appointment_services__page_prev' ).prop( 'disabled', state.page <= 1 );
682 $( '.wpbc_appointment_services__page_next' ).prop( 'disabled', state.total_pages < 1 || state.page >= state.total_pages );
683 }
684 /** Load one Service and open it in the right inspector. */
685 function loadOne( serviceId ) {
686 if ( ! serviceId || state.busy ) { return; }
687 if ( editorIsOpen() && state.selectedId === serviceId ) { expand_service_inspector(); focus_requested_service_section(); return; }
688 if ( ! can_replace_editor() ) { return; }
689 setBusy( true );
690 request( config.actions.load, { service_id: serviceId } ).done( function ( response ) {
691 if ( response && response.success && response.data && response.data.service ) { fillEditor( response.data.service ); updateUrl( state.selectedId ); expand_service_inspector(); focus_requested_service_section(); return; }
692 notify( messageFrom( response, config.i18n.load_failed ), 'error' );
693 } ).fail( function ( xhr ) { notify( messageFrom( xhr.responseJSON, config.i18n.load_failed ), 'error' ); } ).always( function () { setBusy( false ); } );
694 }
695 /** Reload Services and Provider presentation data for the active filters. */
696 function loadList( save_items_per_page ) {
697 var request_data = {
698 search: $( '#wpbc_service_search' ).val() || '',
699 status: state.status,
700 resource_id: $( '#wpbc_service_provider_filter' ).val() || 0,
701 page_number: state.page,
702 items_per_page: state.page_size,
703 sort_by: state.sort_by,
704 sort_order: state.sort_order
705 };
706 if ( save_items_per_page ) { request_data.save_items_per_page = 1; }
707 setLoading( true );
708 request( config.actions.list, request_data ).done( function ( response ) {
709 if ( ! response || ! response.success || ! response.data ) { renderEmpty( messageFrom( response, config.i18n.load_failed ), false ); return; }
710 state.storageReady = !! response.data.storage_ready;
711 if ( ! state.storageReady ) { $( '.wpbc_appointment_services__table tbody' ).empty(); setFieldsEnabled( false ); renderEmpty( response.data.message || config.i18n.not_connected, true ); return; }
712 sync_listing_settings( response.data.listing || config.listing || {} );
713 sync_sorting_controls( response.data.sorting || {} );
714 indexProviders( response.data.providers || [] );
715 updateSummary( response.data.counts, response.data.provider_count );
716 renderList( response.data.services || [], response.data.pagination || {} );
717 updateControls();
718 if ( state.selectedId ) { loadOne( state.selectedId ); }
719 } ).fail( function ( xhr ) {
720 state.storageReady = false;
721 setFieldsEnabled( false );
722 renderEmpty( messageFrom( xhr.responseJSON, config.i18n.load_failed ), false );
723 } ).always( function () { setLoading( false ); } );
724 }
725 /** Archive one Service after confirmation, then refresh the list. */
726 function archiveService( serviceId ) {
727 if ( ! serviceId || state.busy || ! w.confirm( config.i18n.confirm_archive || 'Archive this Service?' ) ) { return; }
728 setBusy( true );
729 request( config.actions.archive, { service_id: serviceId } ).done( function ( response ) {
730 if ( response && response.success ) {
731 if ( state.selectedId === serviceId ) { state.selectedId = 0; setFieldsEnabled( false ); updateUrl( 0 ); }
732 notify( response.data.message, 'success' ); loadList(); return;
733 }
734 notify( messageFrom( response, config.i18n.archive_failed ), 'error' );
735 } ).fail( function ( xhr ) { notify( messageFrom( xhr.responseJSON, config.i18n.archive_failed ), 'error' ); } ).always( function () { setBusy( false ); } );
736 }
737
738 $( document ).on( 'click', '.wpbc_appointment_services__rightbar_tabs [role="tab"]', function ( event ) { event.preventDefault(); switchRightPanel( $( this ) ); } );
739 $( document ).on( 'click', '.wpbc_appointment_services__rightbar .wpbc_ui__collapsible_group > .group__header', function ( event ) { event.preventDefault(); toggleInspectorGroup( $( this ) ); } );
740 $( document ).on( 'click', '.wpbc_appointment_services__item', function ( event ) { if ( ! $( event.target ).closest( 'button, a' ).length ) { loadOne( Number( $( this ).data( 'service-id' ) || 0 ) ); } } );
741 $( document ).on( 'keydown', '.wpbc_appointment_services__item', function ( event ) { if ( ! $( event.target ).closest( 'button, a' ).length && ( 'Enter' === event.key || ' ' === event.key ) ) { event.preventDefault(); loadOne( Number( $( this ).data( 'service-id' ) || 0 ) ); } } );
742 $( document ).on( 'click', '.wpbc_appointment_services__row_edit', function () { loadOne( Number( $( this ).data( 'service-id' ) || 0 ) ); } );
743 $( document ).on( 'click', '.wpbc_appointment_services__row_archive', function () { archiveService( Number( $( this ).data( 'service-id' ) || 0 ) ); } );
744 $( document ).on( 'click', '.wpbc_appointment_services__status_filter', function () {
745 state.status = String( $( this ).data( 'service-status' ) || 'all' );
746 state.page = 1;
747 $( '.wpbc_appointment_services__status_filter' ).removeClass( 'is-active' ).attr( 'aria-pressed', 'false' );
748 $( this ).addClass( 'is-active' ).attr( 'aria-pressed', 'true' );
749 loadList();
750 } );
751 $( document ).on( 'click', '.wpbc_appointment_services__page_prev', function () { if ( state.page > 1 ) { state.page--; loadList(); } } );
752 $( document ).on( 'click', '.wpbc_appointment_services__page_next', function () { if ( state.page < state.total_pages ) { state.page++; loadList(); } } );
753 $( document ).on( 'click', '[data-wpbc-listing-sort="appointment_services_catalog"]', function ( event ) {
754 var sort_by = String( $( this ).data( 'wpbc-listing-sort-key' ) || '' );
755
756 event.preventDefault();
757 if ( ! sort_by ) { return; }
758 state.sort_order = sort_by === state.sort_by && 'asc' === state.sort_order ? 'desc' : 'asc';
759 state.sort_by = sort_by;
760 state.page = 1;
761 loadList();
762 } );
763 $( document ).on( 'change', '[data-wpbc-listing-page-number-control="appointment_services_catalog"]', function () { request_selected_page( $( this ) ); } );
764 $( document ).on( 'keydown', '[data-wpbc-listing-page-number-control="appointment_services_catalog"]', function ( event ) {
765 if ( 'Enter' === event.key ) { event.preventDefault(); request_selected_page( $( this ) ); }
766 } );
767 $( document ).on( 'change', '[data-wpbc-listing-items-per-page-control="appointment_services_catalog"]', function () {
768 var page_size = Number( $( this ).val() );
769 if ( ! isFinite( page_size ) || page_size < 1 || page_size === state.page_size ) { return; }
770 state.page_size = page_size;
771 state.page = 1;
772 loadList( true );
773 } );
774 $( document ).on( 'click', '.wpbc_appointment_services__add', function () {
775 if ( ! state.storageReady || state.busy || ! can_replace_editor() ) { return; }
776 fillEditor( blankService() );
777 updateUrl( 0 );
778 open_add_service_inspector();
779 } );
780 $( document ).on( 'click', '.wpbc_appointment_services__save', function () {
781 if ( ! state.storageReady || state.busy ) { return; }
782 setBusy( true );
783 request( config.actions.save, { service: collectEditor() } ).done( function ( response ) {
784 if ( response && response.success && response.data && response.data.service ) { fillEditor( response.data.service ); updateUrl( state.selectedId ); notify( response.data.message, 'success' ); loadList(); return; }
785 notify( messageFrom( response, config.i18n.save_failed ), 'error' );
786 } ).fail( function ( xhr ) { notify( messageFrom( xhr.responseJSON, config.i18n.save_failed ), 'error' ); } ).always( function () { setBusy( false ); } );
787 } );
788 $( document ).on( 'click', '.wpbc_appointment_services__duplicate', function () {
789 if ( ! state.selectedId || state.busy ) { return; }
790 setBusy( true );
791 request( config.actions.duplicate, { service_id: state.selectedId } ).done( function ( response ) {
792 if ( response && response.success && response.data && response.data.service ) { fillEditor( response.data.service ); updateUrl( state.selectedId ); notify( response.data.message, 'success' ); loadList(); return; }
793 notify( messageFrom( response, config.i18n.duplicate_failed ), 'error' );
794 } ).fail( function ( xhr ) { notify( messageFrom( xhr.responseJSON, config.i18n.duplicate_failed ), 'error' ); } ).always( function () { setBusy( false ); } );
795 } );
796 $( document ).on( 'click', '.wpbc_appointment_services__archive', function () { archiveService( state.selectedId ); } );
797 $( document ).on( 'input change', '[data-service-range-field]', function () {
798 var field_id = String( $( this ).data( 'service-range-field' ) || '' );
799 if ( field_id ) { $( '[data-service-field="' + field_id + '"]' ).val( $( this ).val() ).trigger( 'input' ); }
800 } );
801 $( document ).on( 'input change', 'input[type="number"][data-service-field]', function () { sync_numeric_range( String( $( this ).data( 'service-field' ) || '' ) ); } );
802 $( document ).on( 'change', '[data-service-status-choice]', function () {
803 if ( this.checked ) { $( '[data-service-field="status"]' ).val( this.value ).trigger( 'change' ); }
804 } );
805 $( document ).on( 'input change wpbc_media_upload_url_set', '[data-service-field="picture_url"]', function () { updateMediaPreview(); updateControls(); } );
806 $( document ).on( 'click', '.wpbc_appointment_services__remove_image', function () {
807 if ( $( this ).prop( 'disabled' ) ) { return; }
808 $( '[data-service-field="picture_url"]' ).val( '' ).trigger( 'input' ).trigger( 'change' );
809 } );
810 $( document ).on( 'input', '#wpbc_service_search', function () { w.clearTimeout( searchTimer ); state.page = 1; searchTimer = w.setTimeout( loadList, 250 ); } );
811 $( document ).on( 'change', '#wpbc_service_provider_filter', function () { state.page = 1; loadList(); } );
812 $( function () { if ( $( '[data-wpbc-appointment-services-page="1"]' ).length ) { loadList(); } } );
813 } )( window, jQuery );
814