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

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

636 lines 21.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Provide the reusable progressively saved Service editor used by Setup Wizard.
3 *
4 * The module owns Service presentation, local draft synchronization, media
5 * preview behavior, and early validation. Persistence and authorization remain
6 * server-owned. The number input remains authoritative when paired with a
7 * range input so keyboard users can enter exact minute values.
8 *
9 * @package Booking Calendar
10 */
11 ( function ( $, window, document ) {
12 'use strict';
13
14 var module_config = window.wpbc_setup_wizard_services || { i18n: {} };
15
16 /**
17 * Create one isolated Service editor adapter.
18 *
19 * @param {Object} config Module translations and presentation settings.
20 * @return {Object} Step adapter accepted by the shared wizard shell.
21 */
22 function create_services_adapter( config ) {
23 var shell_api = null;
24 var root = null;
25 var editor = null;
26
27 /**
28 * Return one translated string with a safe fallback.
29 *
30 * @param {string} key Translation key.
31 * @param {string} fallback Fallback copy.
32 * @return {string} Translation or fallback.
33 */
34 function get_message( key, fallback ) {
35 return config.i18n && config.i18n[ key ] ? config.i18n[ key ] : fallback;
36 }
37
38 /**
39 * Replace one string placeholder without interpreting replacement tokens.
40 *
41 * @param {string} message Translated message containing `%s`.
42 * @param {string} replacement Plain-text replacement.
43 * @return {string} Formatted message.
44 */
45 function format_message( message, replacement ) {
46 return String( message ).replace( '%s', function () {
47 return String( replacement );
48 } );
49 }
50
51 /**
52 * Create one unsaved Service proposal using safe presentation defaults.
53 *
54 * @return {Object} JSON-safe Service proposal.
55 */
56 function create_service_draft() {
57 return {
58 draft_id: 'draft-' + Date.now() + '-' + Math.floor( Math.random() * 100000 ),
59 source_service_id: 0,
60 title: get_message( 'new_service', 'New Service' ),
61 description: '',
62 picture_url: '',
63 duration_minutes: 30,
64 buffer_before_minutes: 0,
65 buffer_after_minutes: 0,
66 base_cost: '0.00',
67 status: 'active'
68 };
69 }
70
71 /**
72 * Return one editable Service field control.
73 *
74 * @param {string} field_name Stable Service DTO field.
75 * @return {HTMLElement|null} Matching field control.
76 */
77 function get_service_field( field_name ) {
78 return editor ? editor.node.querySelector( '[data-wpbc-service-field="' + field_name + '"]' ) : null;
79 }
80
81 /**
82 * Synchronize both explicit Service transport fields.
83 *
84 * Canonical lifecycle intent is kept separate from the visible proposal
85 * collection so omission alone can never move a Service to Draft.
86 *
87 * @return {void}
88 */
89 function sync_transports() {
90 if ( ! editor ) {
91 return;
92 }
93
94 editor.transport.value = JSON.stringify( editor.services );
95 editor.inactive_transport.value = JSON.stringify( editor.inactive_service_ids );
96 }
97
98 /**
99 * Synchronize a range input from its authoritative number control.
100 *
101 * Invalid number input is intentionally left visible for validation and is
102 * not allowed to coerce the stored draft silently.
103 *
104 * @param {string} field_name Stable numeric Service field.
105 * @return {void}
106 */
107 function sync_range_from_number( field_name ) {
108 var number_control = get_service_field( field_name );
109 var range_control = editor && editor.node.querySelector( '[data-wpbc-service-range="' + field_name + '"]' );
110 var number_value;
111
112 if ( ! number_control || ! range_control ) {
113 return;
114 }
115
116 number_value = Number( number_control.value );
117 if ( isFinite( number_value ) && number_value >= Number( range_control.min ) && number_value <= Number( range_control.max ) ) {
118 range_control.value = String( number_value );
119 }
120 }
121
122 /**
123 * Persist the current detail controls into the in-memory draft collection.
124 *
125 * @return {void}
126 */
127 function sync_selected_service() {
128 var service;
129
130 if ( ! editor || ! editor.services[ editor.selected_index ] ) {
131 return;
132 }
133
134 service = editor.services[ editor.selected_index ];
135 [ 'title', 'description', 'picture_url', 'duration_minutes', 'buffer_before_minutes', 'buffer_after_minutes', 'base_cost' ].forEach( function ( field_name ) {
136 var field = get_service_field( field_name );
137 if ( field && ! field.disabled ) {
138 service[ field_name ] = field.value;
139 }
140 } );
141 service.status = 'active';
142 sync_transports();
143 }
144
145 /**
146 * Update the Service picture preview from the URL control.
147 *
148 * @return {void}
149 */
150 function update_service_picture() {
151 var url_field = get_service_field( 'picture_url' );
152 var image = editor && editor.node.querySelector( '[data-wpbc-service-picture-image]' );
153 var placeholder = editor && editor.node.querySelector( '[data-wpbc-service-picture-placeholder]' );
154 var remove_button = editor && editor.node.querySelector( '[data-wpbc-remove-service-picture]' );
155 var picture_url = url_field ? String( url_field.value || '' ).trim() : '';
156
157 if ( image ) {
158 image.hidden = ! picture_url;
159 if ( picture_url ) {
160 image.src = picture_url;
161 } else {
162 image.removeAttribute( 'src' );
163 }
164 }
165 if ( placeholder ) {
166 placeholder.hidden = Boolean( picture_url );
167 }
168 if ( remove_button ) {
169 remove_button.disabled = ! picture_url;
170 }
171 }
172
173 /**
174 * Format a Service duration as localized hours and minutes.
175 *
176 * The formatter is presentation-only. Canonical Service draft values remain
177 * integer minutes for validation and persistence.
178 *
179 * @param {number|string} duration_minutes Canonical duration in minutes.
180 * @return {string} Human-readable duration such as "2 hours and 3 minutes".
181 */
182 function format_service_duration( duration_minutes ) {
183 var parsed_minutes = Number( duration_minutes );
184 var total_minutes = isFinite( parsed_minutes ) && parsed_minutes >= 0 ? Math.floor( parsed_minutes ) : 0;
185 var hours = Math.floor( total_minutes / 60 );
186 var minutes = total_minutes % 60;
187 var duration_parts = [];
188 var locale = document.documentElement.getAttribute( 'lang' ) || undefined;
189
190 if ( window.Intl && 'function' === typeof window.Intl.NumberFormat ) {
191 try {
192 if ( hours ) {
193 duration_parts.push( new window.Intl.NumberFormat( locale, { style: 'unit', unit: 'hour', unitDisplay: 'long' } ).format( hours ) );
194 }
195 if ( minutes || ! hours ) {
196 duration_parts.push( new window.Intl.NumberFormat( locale, { style: 'unit', unit: 'minute', unitDisplay: 'long' } ).format( minutes ) );
197 }
198 } catch ( formatter_error ) {
199 duration_parts = [];
200 }
201 }
202
203 if ( ! duration_parts.length ) {
204 if ( hours ) {
205 duration_parts.push( String( hours ) + ' ' + get_message( 1 === hours ? 'hour_singular' : 'hour_plural', 1 === hours ? 'hour' : 'hours' ) );
206 }
207 if ( minutes || ! hours ) {
208 duration_parts.push( String( minutes ) + ' ' + get_message( 1 === minutes ? 'minute_singular' : 'minute_plural', 1 === minutes ? 'minute' : 'minutes' ) );
209 }
210 }
211
212 if ( 1 === duration_parts.length ) {
213 return duration_parts[0];
214 }
215
216 if ( window.Intl && 'function' === typeof window.Intl.ListFormat ) {
217 try {
218 return new window.Intl.ListFormat( locale, { style: 'long', type: 'conjunction' } ).format( duration_parts );
219 } catch ( list_formatter_error ) {
220 // Fall through to the translated conjunction for older browsers.
221 }
222 }
223
224 return duration_parts.join( ' ' + get_message( 'duration_joiner', 'and' ) + ' ' );
225 }
226
227 /**
228 * Update the customer-facing title and duration preview.
229 *
230 * @return {void}
231 */
232 function update_service_summary() {
233 var summary = editor && editor.node.querySelector( '[data-wpbc-service-summary]' );
234 var title = get_service_field( 'title' );
235 var duration = get_service_field( 'duration_minutes' );
236
237 if ( summary ) {
238 summary.textContent = ( title && title.value.trim() ? title.value.trim() : get_message( 'new_service', 'New Service' ) ) + ' · ' + format_service_duration( duration ? duration.value : 30 );
239 }
240 }
241
242 /**
243 * Load the selected proposal into the detail controls and preview.
244 *
245 * @return {void}
246 */
247 function load_selected_service() {
248 var service;
249
250 if ( ! editor ) {
251 return;
252 }
253
254 service = editor.services[ editor.selected_index ];
255 if ( ! service ) {
256 return;
257 }
258
259 [ 'title', 'description', 'picture_url', 'duration_minutes', 'buffer_before_minutes', 'buffer_after_minutes', 'base_cost' ].forEach( function ( field_name ) {
260 var field = get_service_field( field_name );
261 if ( field ) {
262 field.value = null === service[ field_name ] || undefined === service[ field_name ] ? '' : service[ field_name ];
263 }
264 } );
265
266 [ 'duration_minutes', 'buffer_before_minutes', 'buffer_after_minutes' ].forEach( sync_range_from_number );
267 update_service_picture();
268 update_service_summary();
269 }
270
271 /**
272 * Render the proposal chooser from the data-only draft collection.
273 *
274 * @return {void}
275 */
276 function render_service_cards() {
277 if ( ! editor || ! editor.list || ! editor.template ) {
278 return;
279 }
280
281 editor.list.textContent = '';
282 editor.services.forEach( function ( service, service_index ) {
283 var card = editor.template.content.firstElementChild.cloneNode( true );
284 var radio = card.querySelector( 'input[type="radio"]' );
285 var image = card.querySelector( 'img' );
286 var placeholder = card.querySelector( 'i' );
287 var title = card.querySelector( 'strong' );
288 var duration = card.querySelector( '[data-wpbc-service-card-duration]' );
289 var remove_button = card.querySelector( '[data-wpbc-remove-service]' );
290 var picture_url = String( service.picture_url || '' ).trim();
291 var service_title = service.title || get_message( 'new_service', 'New Service' );
292
293 radio.value = String( service_index );
294 radio.checked = service_index === editor.selected_index;
295 radio.dataset.wpbcServiceSelection = String( service_index );
296 card.classList.toggle( 'is-selected', radio.checked );
297 title.textContent = service_title;
298 duration.textContent = format_service_duration( null === service.duration_minutes || undefined === service.duration_minutes || '' === service.duration_minutes ? 30 : service.duration_minutes );
299 if ( remove_button ) {
300 remove_button.dataset.wpbcRemoveService = String( service_index );
301 remove_button.disabled = 1 === editor.services.length;
302 if ( Number( service.source_service_id ) > 0 ) {
303 remove_button.setAttribute( 'aria-label', format_message( get_message( 'move_service_to_draft', 'Move %s to Draft' ), service_title ) );
304 remove_button.title = format_message( get_message( 'move_service_to_draft', 'Move %s to Draft' ), service_title );
305 } else {
306 remove_button.setAttribute( 'aria-label', format_message( get_message( 'remove_service', 'Remove %s' ), service_title ) );
307 remove_button.title = format_message( get_message( 'remove_service', 'Remove %s' ), service_title );
308 }
309 }
310 if ( picture_url ) {
311 image.src = picture_url;
312 image.hidden = false;
313 placeholder.hidden = true;
314 } else {
315 image.hidden = true;
316 image.removeAttribute( 'src' );
317 placeholder.hidden = false;
318 }
319 editor.list.appendChild( card );
320 } );
321 }
322
323 /**
324 * Remove one proposal and explicitly stage a canonical Service for Draft.
325 *
326 * The required final Service cannot be removed. Canonical storage is not
327 * changed until Save; unsaved proposals have no canonical lifecycle action.
328 *
329 * @param {number} service_index Zero-based Service draft index.
330 * @return {void}
331 */
332 function remove_service( service_index ) {
333 var focus_target;
334 var removed_service;
335 var source_service_id;
336 var status_message;
337
338 if ( ! editor || ! isFinite( service_index ) || service_index !== Math.floor( service_index ) || editor.services.length <= 1 || service_index < 0 || service_index >= editor.services.length ) {
339 return;
340 }
341
342 sync_selected_service();
343 removed_service = editor.services[ service_index ];
344 source_service_id = Number( removed_service.source_service_id || 0 );
345 if ( source_service_id > 0 && -1 === editor.inactive_service_ids.indexOf( source_service_id ) ) {
346 editor.inactive_service_ids.push( source_service_id );
347 }
348 editor.services.splice( service_index, 1 );
349
350 if ( service_index < editor.selected_index ) {
351 editor.selected_index -= 1;
352 } else if ( service_index === editor.selected_index ) {
353 editor.selected_index = Math.min( service_index, editor.services.length - 1 );
354 }
355
356 render_service_editor();
357 shell_api.clear_field_error( 'services' );
358 status_message = source_service_id > 0
359 ? format_message( get_message( 'service_draft_pending', '%s will move to Draft when you save.' ), removed_service.title || get_message( 'new_service', 'New Service' ) )
360 : get_message( 'service_removed', 'Unsaved Service removed from this setup.' );
361 shell_api.set_status( status_message );
362
363 focus_target = editor.list.querySelector( '[data-wpbc-remove-service="' + editor.selected_index + '"]' );
364 if ( focus_target && focus_target.disabled ) {
365 focus_target = editor.node.querySelector( '[data-wpbc-add-service]' );
366 }
367 if ( focus_target ) {
368 focus_target.focus();
369 }
370 }
371
372 /**
373 * Refresh all Service editor presentation and its JSON transport field.
374 *
375 * @return {void}
376 */
377 function render_service_editor() {
378 var add_button;
379
380 if ( ! editor ) {
381 return;
382 }
383
384 editor.selected_index = Math.min( editor.selected_index, editor.services.length - 1 );
385 sync_transports();
386 render_service_cards();
387 load_selected_service();
388 add_button = editor.node.querySelector( '[data-wpbc-add-service]' );
389 if ( add_button ) {
390 add_button.disabled = editor.services.length >= editor.max_services;
391 }
392 }
393
394 /**
395 * Handle Service editor button actions.
396 *
397 * @param {MouseEvent} event Delegated click event.
398 * @return {void}
399 */
400 function handle_click( event ) {
401 var add_button = event.target.closest( '[data-wpbc-add-service]' );
402 var remove_service_button = event.target.closest( '[data-wpbc-remove-service]' );
403 var remove_picture_button = event.target.closest( '[data-wpbc-remove-service-picture]' );
404
405 if ( remove_service_button ) {
406 event.preventDefault();
407 event.stopPropagation();
408 remove_service( parseInt( remove_service_button.dataset.wpbcRemoveService, 10 ) );
409 return;
410 }
411
412 if ( add_button ) {
413 event.preventDefault();
414 if ( editor.services.length >= editor.max_services ) {
415 shell_api.set_status( get_message( 'service_limit', 'The maximum number of Service drafts has been reached.' ) );
416 return;
417 }
418
419 sync_selected_service();
420 editor.services.push( create_service_draft() );
421 editor.selected_index = editor.services.length - 1;
422 render_service_editor();
423 get_service_field( 'title' ).select();
424 return;
425 }
426
427 if ( remove_picture_button ) {
428 event.preventDefault();
429 get_service_field( 'picture_url' ).value = '';
430 sync_selected_service();
431 update_service_picture();
432 render_service_cards();
433 }
434 }
435
436 /**
437 * Handle selection and committed field changes.
438 *
439 * @param {Event} event Delegated change event.
440 * @return {void}
441 */
442 function handle_change( event ) {
443 if ( event.target.matches( '[data-wpbc-service-selection]' ) ) {
444 sync_selected_service();
445 editor.selected_index = parseInt( event.target.dataset.wpbcServiceSelection, 10 );
446 render_service_editor();
447 return;
448 }
449
450 if ( event.target.matches( '[data-wpbc-service-field]' ) ) {
451 sync_range_from_number( event.target.dataset.wpbcServiceField );
452 sync_selected_service();
453 update_service_picture();
454 update_service_summary();
455 render_service_cards();
456 shell_api.clear_field_error( 'services' );
457 }
458 }
459
460 /**
461 * Handle live number, range, and text edits.
462 *
463 * @param {InputEvent} event Delegated input event.
464 * @return {void}
465 */
466 function handle_input( event ) {
467 var field_name;
468 var number_control;
469
470 if ( event.target.matches( '[data-wpbc-service-range]' ) ) {
471 field_name = event.target.dataset.wpbcServiceRange;
472 number_control = get_service_field( field_name );
473 if ( number_control ) {
474 number_control.value = event.target.value;
475 }
476 } else if ( event.target.matches( '[data-wpbc-service-field]' ) ) {
477 field_name = event.target.dataset.wpbcServiceField;
478 sync_range_from_number( field_name );
479 } else {
480 return;
481 }
482
483 sync_selected_service();
484 update_service_picture();
485 update_service_summary();
486 render_service_cards();
487 shell_api.clear_field_error( 'services' );
488 }
489
490 /**
491 * Refresh the media preview after the shared WordPress picker writes a URL.
492 *
493 * @return {void}
494 */
495 function handle_media_url_set() {
496 if ( ! editor ) {
497 return;
498 }
499
500 sync_selected_service();
501 update_service_picture();
502 render_service_cards();
503 }
504
505 /**
506 * Initialize this module against shell-provided services.
507 *
508 * @param {Object} registered_shell_api Shared wizard adapter services.
509 * @return {void}
510 */
511 function initialize( registered_shell_api ) {
512 var parsed_services;
513 var parsed_inactive_service_ids;
514 var editor_node;
515 var transport;
516 var inactive_transport;
517
518 shell_api = registered_shell_api;
519 root = shell_api.root;
520 editor_node = root.querySelector( '[data-wpbc-services-editor]' );
521 transport = root.querySelector( '[data-wpbc-service-draft]' );
522 inactive_transport = root.querySelector( '[data-wpbc-inactive-service-ids]' );
523 if ( ! editor_node || ! transport || ! inactive_transport ) {
524 return;
525 }
526
527 try {
528 parsed_services = JSON.parse( transport.value || '[]' );
529 } catch ( parse_error ) {
530 parsed_services = [];
531 }
532 if ( ! Array.isArray( parsed_services ) ) {
533 parsed_services = [];
534 }
535 try {
536 parsed_inactive_service_ids = JSON.parse( inactive_transport.value || '[]' );
537 } catch ( inactive_parse_error ) {
538 parsed_inactive_service_ids = [];
539 }
540 if ( ! Array.isArray( parsed_inactive_service_ids ) ) {
541 parsed_inactive_service_ids = [];
542 }
543 parsed_inactive_service_ids = parsed_inactive_service_ids.map( Number ).filter( function ( service_id, service_index, service_ids ) {
544 return isFinite( service_id ) && service_id === Math.floor( service_id ) && service_id > 0 && service_ids.indexOf( service_id ) === service_index;
545 } );
546
547 editor = {
548 node: editor_node,
549 transport: transport,
550 inactive_transport: inactive_transport,
551 list: editor_node.querySelector( '[data-wpbc-services-list]' ),
552 template: root.querySelector( '[data-wpbc-service-card-template]' ),
553 services: parsed_services,
554 inactive_service_ids: parsed_inactive_service_ids,
555 selected_index: 0,
556 max_services: parseInt( transport.dataset.maxServices || '20', 10 ),
557 pricing_available: 'true' === editor_node.dataset.pricingAvailable
558 };
559
560 if ( ! editor.services.length ) {
561 editor.services.push( create_service_draft() );
562 }
563
564 editor.node.addEventListener( 'click', handle_click );
565 editor.node.addEventListener( 'change', handle_change );
566 editor.node.addEventListener( 'input', handle_input );
567 $( document ).on( 'wpbc_media_upload_url_set.wpbcSetupWizardServices', '#wpbc-setup-wizard-service-picture-url', handle_media_url_set );
568 render_service_editor();
569 }
570
571 /**
572 * Validate all proposed Services before forward navigation.
573 *
574 * @return {HTMLElement|null} First invalid detail control, or null.
575 */
576 function validate() {
577 var first_invalid = null;
578
579 if ( ! editor || ! editor.services.length ) {
580 return shell_api.set_field_error( 'services', get_message( 'service_title_required', 'Enter a title for every Service.' ) );
581 }
582
583 sync_selected_service();
584 editor.services.some( function ( service, service_index ) {
585 var invalid_field_name = '';
586 var numeric_fields = [
587 { key: 'duration_minutes', min: 1, max: 1440 },
588 { key: 'buffer_before_minutes', min: 0, max: 1440 },
589 { key: 'buffer_after_minutes', min: 0, max: 1440 }
590 ];
591 var invalid_number = numeric_fields.some( function ( rule ) {
592 var number_value = Number( service[ rule.key ] );
593 var is_invalid = ! /^\d+$/.test( String( service[ rule.key ] ) ) || number_value < rule.min || number_value > rule.max;
594
595 if ( is_invalid ) {
596 invalid_field_name = rule.key;
597 }
598 return is_invalid;
599 } );
600 var base_cost = Number( service.base_cost );
601
602 if ( ! invalid_number && editor.pricing_available && ( ! isFinite( base_cost ) || base_cost < 0 || base_cost > 1000 ) ) {
603 invalid_number = true;
604 invalid_field_name = 'base_cost';
605 }
606
607 if ( ! String( service.title || '' ).trim() || invalid_number ) {
608 editor.selected_index = service_index;
609 render_service_editor();
610 first_invalid = get_service_field( ! String( service.title || '' ).trim() ? 'title' : invalid_field_name );
611 shell_api.set_field_error( 'services', ! String( service.title || '' ).trim() ? get_message( 'service_title_required', 'Enter a title for every Service.' ) : get_message( 'service_number_invalid', 'Enter a valid number within the available range.' ) );
612 return true;
613 }
614 return false;
615 } );
616
617 return first_invalid;
618 }
619
620 return {
621 initialize: initialize,
622 sync: sync_selected_service,
623 validate: validate
624 };
625 }
626
627 window.wpbc_setup_editor_modules = window.wpbc_setup_editor_modules || {};
628 window.wpbc_setup_editor_modules.services = {
629 create: create_services_adapter
630 };
631
632 if ( window.wpbc_setup_wizard_api && 'function' === typeof window.wpbc_setup_wizard_api.register_step_adapter ) {
633 window.wpbc_setup_wizard_api.register_step_adapter( create_services_adapter( module_config ) );
634 }
635 }( jQuery, window, document ) );
636