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-fixed-time-slots / _src / step-fixed-time-slots.js

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

604 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Fixed Time Slots editor adapter for the Setup Wizard.
3 *
4 * @package Booking Calendar
5 */
6 ( function ( window ) {
7 'use strict';
8
9 var config = window.wpbc_setup_wizard_fixed_time_slots || { i18n: {} };
10 var module_config = {
11 module_id: config.module_id || 'fixed_time_slots',
12 field_id: config.field_id || 'fixed_time_slots'
13 };
14
15 /**
16 * Create one fixed-slot editor adapter.
17 *
18 * @param {Object} runtime_config Server-owned module configuration.
19 * @return {Object} Setup Wizard step adapter.
20 */
21 function create_fixed_time_slots_adapter( runtime_config ) {
22 var shell_api = null;
23 var editor = null;
24
25 /**
26 * Return one localized message with a safe fallback.
27 *
28 * @param {string} key Message key.
29 * @param {string} fallback Fallback text.
30 * @return {string} Message text.
31 */
32 function get_message( key, fallback ) {
33 return config.i18n && config.i18n[ key ] ? config.i18n[ key ] : fallback;
34 }
35
36 /**
37 * Convert one exact clock value to minutes after day start.
38 *
39 * @param {string} time_value Clock value.
40 * @param {boolean} allow_day_end Whether `24:00` is accepted.
41 * @return {number|null} Minute count or null.
42 */
43 function time_to_minutes( time_value, allow_day_end ) {
44 var match;
45
46 if ( allow_day_end && '24:00' === time_value ) {
47 return 24 * 60;
48 }
49 match = /^(?:[01]\d|2[0-3]):[0-5]\d$/.exec( String( time_value || '' ) );
50 if ( ! match ) {
51 return null;
52 }
53
54 return ( parseInt( time_value.substr( 0, 2 ), 10 ) * 60 ) + parseInt( time_value.substr( 3, 2 ), 10 );
55 }
56
57 /**
58 * Convert bounded minute count to `HH:MM`.
59 *
60 * @param {number} minutes Minutes from day start.
61 * @return {string} Clock value.
62 */
63 function minutes_to_time( minutes ) {
64 var hour = Math.floor( minutes / 60 );
65 var minute = minutes % 60;
66
67 return String( hour ).padStart( 2, '0' ) + ':' + String( minute ).padStart( 2, '0' );
68 }
69
70 /**
71 * Return a select option label for one value.
72 *
73 * @param {HTMLSelectElement} select Select control.
74 * @param {string} option_value Option value.
75 * @return {string} Visible label or value.
76 */
77 function get_option_label( select, option_value ) {
78 var option = Array.prototype.find.call( select.options, function ( candidate ) {
79 return candidate.value === option_value;
80 } );
81
82 return option ? option.textContent : option_value;
83 }
84
85 /**
86 * Return a human-readable label for one slot.
87 *
88 * @param {Object} slot Slot DTO.
89 * @param {HTMLSelectElement} start_select Start select.
90 * @param {HTMLSelectElement} end_select End select.
91 * @return {string} Slot label.
92 */
93 function get_slot_label( slot, start_select, end_select ) {
94 return get_option_label( start_select, slot.start_time ) + ' - ' + get_option_label( end_select, slot.end_time );
95 }
96
97 /**
98 * Synchronize the JSON transport with current ordered slots.
99 *
100 * @return {void}
101 */
102 function sync_fixed_slots_draft() {
103 if ( editor ) {
104 editor.transport.value = JSON.stringify( { time_slots: editor.slots } );
105 }
106 }
107
108 /**
109 * Return one rendered slot control by index.
110 *
111 * @param {number} slot_index Slot index.
112 * @return {HTMLElement|null} Slot element or null.
113 */
114 function get_slot_control( slot_index ) {
115 return editor ? editor.list.querySelector( '[data-fixed-slot-index="' + slot_index + '"]' ) : null;
116 }
117
118 /**
119 * Focus the preferred control after rendering.
120 *
121 * @param {number} slot_index Slot index.
122 * @param {string} control_name Control data suffix.
123 * @return {void}
124 */
125 function focus_slot_control( slot_index, control_name ) {
126 var slot_control = get_slot_control( slot_index );
127 var target = slot_control ? slot_control.querySelector( '[data-wpbc-' + control_name + ']' ) : editor.add_button;
128
129 if ( target ) {
130 target.focus();
131 }
132 }
133
134 /**
135 * Render the complete ordered slot list from data-only state.
136 *
137 * @return {void}
138 */
139 function render_slots() {
140 editor.list.textContent = '';
141 editor.add_button.removeAttribute( 'data-wpbc-setup-wizard-error-control-for' );
142 if ( ! editor.slots.length ) {
143 editor.add_button.setAttribute( 'data-wpbc-setup-wizard-error-control-for', editor.field_id );
144 }
145
146 editor.slots.forEach( function ( slot, slot_index ) {
147 var slot_control = editor.template.content.firstElementChild.cloneNode( true );
148 var start_select = slot_control.querySelector( '[data-wpbc-fixed-slot-start]' );
149 var end_select = slot_control.querySelector( '[data-wpbc-fixed-slot-end]' );
150 var start_label = slot_control.querySelector( '[data-wpbc-fixed-slot-start-label]' );
151 var end_label = slot_control.querySelector( '[data-wpbc-fixed-slot-end-label]' );
152 var move_button = slot_control.querySelector( '[data-wpbc-move-fixed-slot]' );
153 var remove_button = slot_control.querySelector( '[data-wpbc-remove-fixed-slot]' );
154 var start_id = 'wpbc-fixed-time-slot-start-' + slot_index;
155 var end_id = 'wpbc-fixed-time-slot-end-' + slot_index;
156 var slot_label;
157
158 slot_control.dataset.fixedSlotIndex = String( slot_index );
159 start_select.id = start_id;
160 end_select.id = end_id;
161 start_select.value = slot.start_time;
162 end_select.value = slot.end_time;
163 start_label.htmlFor = start_id;
164 end_label.htmlFor = end_id;
165 if ( 0 === slot_index ) {
166 start_select.setAttribute( 'data-wpbc-setup-wizard-error-control-for', editor.field_id );
167 }
168
169 slot_label = get_slot_label( slot, start_select, end_select );
170 start_label.textContent = editor.list_label + ' ' + ( slot_index + 1 ) + ': start';
171 end_label.textContent = editor.list_label + ' ' + ( slot_index + 1 ) + ': end';
172 move_button.setAttribute( 'aria-label', get_message( 'move_slot', 'Move fixed time slot %s' ).replace( '%s', slot_label ) );
173 remove_button.setAttribute( 'aria-label', get_message( 'remove_slot', 'Remove fixed time slot %s' ).replace( '%s', slot_label ) );
174 editor.list.appendChild( slot_control );
175 } );
176
177 editor.add_button.disabled = editor.slots.length >= editor.max_time_slots;
178 editor.clear_button.disabled = ! editor.slots.length;
179 sync_fixed_slots_draft();
180 }
181
182 /**
183 * Mark one slot control invalid and return the focus target.
184 *
185 * @param {string} message Validation message.
186 * @param {number} slot_index Slot index.
187 * @param {string} control_name Either start or end control suffix.
188 * @return {HTMLElement|null} Invalid control or null.
189 */
190 function set_slot_error( message, slot_index, control_name ) {
191 var slot_control = get_slot_control( slot_index );
192 var target = slot_control ? slot_control.querySelector( '[data-wpbc-fixed-slot-' + control_name + ']' ) : editor.add_button;
193
194 shell_api.set_field_error( editor.field_id, message );
195 if ( target ) {
196 target.setAttribute( 'aria-invalid', 'true' );
197 }
198
199 return target;
200 }
201
202 /**
203 * Generate ordered fixed slots from independent spacing and duration.
204 *
205 * @return {void}
206 */
207 function generate_slots() {
208 var from_minutes = time_to_minutes( editor.generator_from.value, false );
209 var to_minutes = time_to_minutes( editor.generator_to.value, false );
210 var spacing_minutes = parseInt( editor.generator_interval.value, 10 );
211 var duration_minutes = parseInt( editor.generator_duration.value, 10 );
212 var generated_slots = [];
213 var start_minutes;
214
215 editor.generator_to.removeAttribute( 'aria-invalid' );
216 editor.generator_duration.removeAttribute( 'aria-invalid' );
217 if (
218 null === from_minutes
219 || null === to_minutes
220 || to_minutes < from_minutes
221 || ! isFinite( spacing_minutes )
222 || ! isFinite( duration_minutes )
223 || spacing_minutes < editor.time_increment
224 || duration_minutes < editor.time_increment
225 || 0 !== spacing_minutes % editor.time_increment
226 || 0 !== duration_minutes % editor.time_increment
227 ) {
228 editor.generator_to.setAttribute( 'aria-invalid', 'true' );
229 shell_api.set_status( get_message( 'generator_invalid', 'Choose a valid first start, last start, spacing, and duration.' ) );
230 editor.generator_to.focus();
231 return;
232 }
233
234 if ( to_minutes + duration_minutes > 24 * 60 ) {
235 editor.generator_duration.setAttribute( 'aria-invalid', 'true' );
236 shell_api.set_status( get_message( 'generator_day_overflow', 'The generated slot duration must finish by the end of the day.' ) );
237 editor.generator_duration.focus();
238 return;
239 }
240
241 for ( start_minutes = from_minutes; start_minutes <= to_minutes && generated_slots.length < editor.max_time_slots; start_minutes += spacing_minutes ) {
242 generated_slots.push( {
243 start_time: minutes_to_time( start_minutes ),
244 end_time: minutes_to_time( start_minutes + duration_minutes )
245 } );
246 }
247
248 editor.slots = generated_slots;
249 render_slots();
250 focus_slot_control( 0, 'fixed-slot-start' );
251 shell_api.clear_field_error( editor.field_id );
252 }
253
254 /**
255 * Determine whether one slot already exists.
256 *
257 * @param {string} start_time Start time.
258 * @param {string} end_time End time.
259 * @return {boolean} True when the exact range exists.
260 */
261 function has_slot( start_time, end_time ) {
262 return editor.slots.some( function ( slot ) {
263 return slot.start_time === start_time && slot.end_time === end_time;
264 } );
265 }
266
267 /**
268 * Find a valid unused slot proposal using current generator duration.
269 *
270 * @return {Object|null} Proposed slot or null.
271 */
272 function find_slot_proposal() {
273 var duration_minutes = parseInt( editor.generator_duration.value, 10 );
274 var preferred_start = editor.slots.length ? editor.slots[ editor.slots.length - 1 ].end_time : editor.generator_from.value;
275 var preferred_index = editor.start_values.indexOf( preferred_start );
276 var offset;
277
278 if ( ! isFinite( duration_minutes ) || duration_minutes < editor.time_increment ) {
279 duration_minutes = 30;
280 }
281 preferred_index = -1 === preferred_index ? 0 : preferred_index;
282
283 for ( offset = 0; offset < editor.start_values.length; offset += 1 ) {
284 var start_time = editor.start_values[ ( preferred_index + offset ) % editor.start_values.length ];
285 var start_minutes = time_to_minutes( start_time, false );
286 var end_minutes = start_minutes + duration_minutes;
287 var end_time = minutes_to_time( end_minutes );
288
289 if ( end_minutes <= 24 * 60 && -1 !== editor.end_values.indexOf( end_time ) && ! has_slot( start_time, end_time ) ) {
290 return { start_time: start_time, end_time: end_time };
291 }
292 }
293
294 return null;
295 }
296
297 /**
298 * Add one valid unused fixed slot.
299 *
300 * @return {void}
301 */
302 function add_slot() {
303 var proposal;
304
305 if ( editor.slots.length >= editor.max_time_slots ) {
306 shell_api.set_status( get_message( 'slot_limit', 'No additional fixed time slot can be added.' ) );
307 return;
308 }
309
310 proposal = find_slot_proposal();
311 if ( ! proposal ) {
312 shell_api.set_status( get_message( 'slot_limit', 'No additional fixed time slot can be added.' ) );
313 return;
314 }
315
316 editor.slots.push( proposal );
317 render_slots();
318 focus_slot_control( editor.slots.length - 1, 'fixed-slot-start' );
319 shell_api.clear_field_error( editor.field_id );
320 }
321
322 /**
323 * Clear every slot while preserving keyboard focus in the action area.
324 *
325 * @return {void}
326 */
327 function clear_slots() {
328 if ( ! editor.slots.length ) {
329 return;
330 }
331
332 editor.slots = [];
333 render_slots();
334 editor.add_button.focus();
335 shell_api.clear_field_error( editor.field_id );
336 shell_api.set_status( get_message( 'slots_cleared', 'Fixed time slots cleared.' ) );
337 }
338
339 /**
340 * Move one slot within the ordered list.
341 *
342 * @param {number} source_index Current index.
343 * @param {number} target_index Target index.
344 * @return {void}
345 */
346 function move_slot( source_index, target_index ) {
347 var moved_slot;
348
349 if ( source_index === target_index || source_index < 0 || target_index < 0 || source_index >= editor.slots.length || target_index >= editor.slots.length ) {
350 return;
351 }
352
353 moved_slot = editor.slots.splice( source_index, 1 )[ 0 ];
354 editor.slots.splice( target_index, 0, moved_slot );
355 render_slots();
356 focus_slot_control( target_index, 'move-fixed-slot' );
357 shell_api.clear_field_error( editor.field_id );
358 }
359
360 /**
361 * Initialize SortableJS for the fixed-slot list.
362 *
363 * @return {void}
364 */
365 function initialize_sortable() {
366 if ( 'function' !== typeof window.Sortable ) {
367 return;
368 }
369
370 editor.sortable = new window.Sortable( editor.list, {
371 animation: 120,
372 draggable: '[data-fixed-slot-index]',
373 handle: '[data-wpbc-move-fixed-slot]',
374 ghostClass: 'is-drag-placeholder',
375 chosenClass: 'is-dragging',
376 dragClass: 'is-dragging',
377 onEnd: function ( event ) {
378 var source_index = Number( event.oldIndex );
379 var target_index = Number( event.newIndex );
380
381 if ( Number.isInteger( source_index ) && Number.isInteger( target_index ) ) {
382 move_slot( source_index, target_index );
383 } else {
384 render_slots();
385 }
386 }
387 } );
388 }
389
390 /**
391 * Handle generator, add, clear, and remove actions.
392 *
393 * @param {MouseEvent} event Delegated click event.
394 * @return {void}
395 */
396 function handle_click( event ) {
397 var slot_control;
398 var slot_index;
399
400 if ( event.target.closest( '[data-wpbc-generate-fixed-slots]' ) ) {
401 event.preventDefault();
402 generate_slots();
403 return;
404 }
405 if ( event.target.closest( '[data-wpbc-add-fixed-slot]' ) ) {
406 event.preventDefault();
407 add_slot();
408 return;
409 }
410 if ( event.target.closest( '[data-wpbc-clear-fixed-slots]' ) ) {
411 event.preventDefault();
412 clear_slots();
413 return;
414 }
415
416 slot_control = event.target.closest( '[data-fixed-slot-index]' );
417 if ( slot_control && event.target.closest( '[data-wpbc-remove-fixed-slot]' ) ) {
418 event.preventDefault();
419 slot_index = parseInt( slot_control.dataset.fixedSlotIndex, 10 );
420 editor.slots.splice( slot_index, 1 );
421 render_slots();
422 focus_slot_control( Math.min( slot_index, editor.slots.length - 1 ), 'fixed-slot-start' );
423 shell_api.clear_field_error( editor.field_id );
424 }
425 }
426
427 /**
428 * Synchronize a changed start or end select.
429 *
430 * @param {Event} event Delegated change event.
431 * @return {void}
432 */
433 function handle_change( event ) {
434 var slot_control = event.target.closest( '[data-fixed-slot-index]' );
435 var slot_index;
436
437 if ( ! slot_control ) {
438 return;
439 }
440 slot_index = parseInt( slot_control.dataset.fixedSlotIndex, 10 );
441 if ( event.target.matches( '[data-wpbc-fixed-slot-start]' ) ) {
442 editor.slots[ slot_index ].start_time = event.target.value;
443 } else if ( event.target.matches( '[data-wpbc-fixed-slot-end]' ) ) {
444 editor.slots[ slot_index ].end_time = event.target.value;
445 } else {
446 return;
447 }
448
449 render_slots();
450 focus_slot_control( slot_index, event.target.matches( '[data-wpbc-fixed-slot-start]' ) ? 'fixed-slot-start' : 'fixed-slot-end' );
451 shell_api.clear_field_error( editor.field_id );
452 }
453
454 /**
455 * Provide keyboard reordering equivalent to drag and drop.
456 *
457 * @param {KeyboardEvent} event Delegated keydown event.
458 * @return {void}
459 */
460 function handle_keydown( event ) {
461 var move_button = event.target.closest( '[data-wpbc-move-fixed-slot]' );
462 var slot_control;
463 var slot_index;
464 var target_index;
465
466 if ( ! move_button || ! [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown' ].includes( event.key ) ) {
467 return;
468 }
469
470 slot_control = move_button.closest( '[data-fixed-slot-index]' );
471 slot_index = parseInt( slot_control.dataset.fixedSlotIndex, 10 );
472 target_index = [ 'ArrowLeft', 'ArrowUp' ].includes( event.key ) ? slot_index - 1 : slot_index + 1;
473 event.preventDefault();
474 move_slot( slot_index, target_index );
475 }
476
477 /**
478 * Initialize this module against shell-provided services.
479 *
480 * @param {Object} registered_shell_api Shared wizard adapter services.
481 * @return {void}
482 */
483 function initialize( registered_shell_api ) {
484 var editor_node;
485 var transport;
486 var parsed_values;
487 var template_start;
488 var template_end;
489
490 shell_api = registered_shell_api;
491 editor_node = shell_api.root.querySelector( '[data-wpbc-fixed-slots-editor]' );
492 transport = shell_api.root.querySelector( '[data-wpbc-fixed-slots-draft]' );
493 if ( ! editor_node || ! transport ) {
494 return;
495 }
496
497 try {
498 parsed_values = JSON.parse( transport.value || '{}' );
499 } catch ( parse_error ) {
500 parsed_values = {};
501 }
502
503 editor = {
504 node: editor_node,
505 transport: transport,
506 field_id: editor_node.dataset.fieldId || runtime_config.field_id || 'fixed_time_slots',
507 slots: parsed_values && Array.isArray( parsed_values.time_slots ) ? parsed_values.time_slots.slice() : [],
508 max_time_slots: parseInt( transport.dataset.maxTimeSlots || String( config.max_time_slots || 288 ), 10 ),
509 time_increment: parseInt( transport.dataset.timeIncrement || String( config.time_increment || 5 ), 10 ),
510 list: editor_node.querySelector( '[data-wpbc-fixed-slot-list]' ),
511 template: editor_node.querySelector( '[data-wpbc-fixed-slot-template]' ),
512 add_button: editor_node.querySelector( '[data-wpbc-add-fixed-slot]' ),
513 clear_button: editor_node.querySelector( '[data-wpbc-clear-fixed-slots]' ),
514 generator_from: editor_node.querySelector( '[data-wpbc-fixed-generator-from]' ),
515 generator_to: editor_node.querySelector( '[data-wpbc-fixed-generator-to]' ),
516 generator_interval: editor_node.querySelector( '[data-wpbc-fixed-generator-interval]' ),
517 generator_duration: editor_node.querySelector( '[data-wpbc-fixed-generator-duration]' ),
518 list_label: '',
519 start_values: [],
520 end_values: [],
521 sortable: null
522 };
523
524 if ( ! editor.list || ! editor.template || ! editor.add_button || ! editor.clear_button || ! editor.generator_from || ! editor.generator_to || ! editor.generator_interval || ! editor.generator_duration ) {
525 editor = null;
526 return;
527 }
528
529 template_start = editor.template.content.querySelector( '[data-wpbc-fixed-slot-start]' );
530 template_end = editor.template.content.querySelector( '[data-wpbc-fixed-slot-end]' );
531 if ( ! template_start || ! template_end ) {
532 editor = null;
533 return;
534 }
535
536 editor.list_label = editor.list.dataset.listLabel || 'Fixed time slots';
537 editor.start_values = Array.prototype.map.call( template_start.options, function ( option ) { return option.value; } );
538 editor.end_values = Array.prototype.map.call( template_end.options, function ( option ) { return option.value; } );
539 editor.node.addEventListener( 'click', handle_click );
540 editor.node.addEventListener( 'change', handle_change );
541 editor.node.addEventListener( 'keydown', handle_keydown );
542 render_slots();
543 initialize_sortable();
544 }
545
546 /**
547 * Validate required, allow-listed, positive, unique fixed slots.
548 *
549 * @return {HTMLElement|null} First invalid control or null.
550 */
551 function validate() {
552 var first_invalid = null;
553 var seen_slots = {};
554
555 if ( ! editor ) {
556 return null;
557 }
558
559 editor.node.querySelectorAll( '[aria-invalid="true"]' ).forEach( function ( control ) {
560 control.removeAttribute( 'aria-invalid' );
561 } );
562
563 if ( ! editor.slots.length ) {
564 first_invalid = set_slot_error( get_message( 'slots_required', 'Add at least one fixed time slot.' ), -1, 'start' );
565 }
566
567 editor.slots.some( function ( slot, slot_index ) {
568 var start_minutes = time_to_minutes( slot.start_time, false );
569 var end_minutes = time_to_minutes( slot.end_time, true );
570 var slot_key = slot.start_time + ' - ' + slot.end_time;
571
572 if ( -1 === editor.start_values.indexOf( slot.start_time ) || -1 === editor.end_values.indexOf( slot.end_time ) || null === start_minutes || null === end_minutes || end_minutes <= start_minutes ) {
573 first_invalid = set_slot_error( get_message( 'slot_invalid', 'Every fixed time slot must end after it starts.' ), slot_index, 'end' );
574 return true;
575 }
576 if ( Object.prototype.hasOwnProperty.call( seen_slots, slot_key ) ) {
577 first_invalid = set_slot_error( get_message( 'slot_duplicate', 'The same fixed time slot cannot appear twice.' ), slot_index, 'start' );
578 return true;
579 }
580 seen_slots[ slot_key ] = true;
581 return false;
582 } );
583
584 sync_fixed_slots_draft();
585 return first_invalid;
586 }
587
588 return {
589 initialize: initialize,
590 sync: sync_fixed_slots_draft,
591 validate: validate
592 };
593 }
594
595 window.wpbc_setup_editor_modules = window.wpbc_setup_editor_modules || {};
596 window.wpbc_setup_editor_modules[ module_config.module_id ] = {
597 create: create_fixed_time_slots_adapter
598 };
599
600 if ( window.wpbc_setup_wizard_api && 'function' === typeof window.wpbc_setup_wizard_api.register_step_adapter ) {
601 window.wpbc_setup_wizard_api.register_step_adapter( create_fixed_time_slots_adapter( module_config ) );
602 }
603 }( window ) );
604