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-working-hours / _src / step-working-hours.js

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

554 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Provide the reusable weekly schedule editor used by Setup Wizard Step 7.
3 *
4 * The module edits a bounded proposal DTO. It supports multiple disjoint
5 * intervals per day and a general source-to-target schedule copy workflow.
6 * Canonical persistence remains server-owned at the explicit save boundary.
7 *
8 * @package Booking Calendar
9 */
10 ( function ( window, document ) {
11 'use strict';
12
13 var module_config = window.wpbc_setup_wizard_working_hours || { i18n: {} };
14
15 /**
16 * Create one isolated Working Hours editor adapter.
17 *
18 * @param {Object} config Module translations and presentation settings.
19 * @return {Object} Step adapter accepted by the shared wizard shell.
20 */
21 function create_working_hours_adapter( config ) {
22 var shell_api = null;
23 var root = null;
24 var editor = null;
25
26 /**
27 * Return one translated string with a safe fallback.
28 *
29 * @param {string} key Translation key.
30 * @param {string} fallback Fallback copy.
31 * @return {string} Translation or fallback.
32 */
33 function get_message( key, fallback ) {
34 return config.i18n && config.i18n[ key ] ? config.i18n[ key ] : fallback;
35 }
36
37 /**
38 * Clone one weekday interval list without sharing object references.
39 *
40 * @param {Object[]} intervals Source intervals.
41 * @return {Object[]} Cloned intervals.
42 */
43 function clone_intervals( intervals ) {
44 return intervals.map( function ( interval ) {
45 return {
46 start_second: interval.start_second,
47 end_second: interval.end_second
48 };
49 } );
50 }
51
52 /**
53 * Write the in-memory weekly schedule to the allow-listed draft field.
54 *
55 * @return {void}
56 */
57 function sync_working_hours_draft() {
58 if ( ! editor ) {
59 return;
60 }
61
62 editor.transport.value = JSON.stringify( {
63 enabled: editor.enabled ? 'On' : 'Off',
64 weekdays: editor.weekdays
65 } );
66 }
67
68 /**
69 * Find a non-overlapping one-hour proposal for a new interval.
70 *
71 * @param {Object[]} intervals Existing intervals for one weekday.
72 * @return {Object|null} Proposed interval, or null when no gap exists.
73 */
74 function find_interval_proposal( intervals ) {
75 var sorted_intervals = intervals.slice().sort( function ( first_interval, second_interval ) {
76 return first_interval.start_second - second_interval.start_second;
77 } );
78 var preferred_start = sorted_intervals.length ? sorted_intervals[ sorted_intervals.length - 1 ].end_second : 9 * 3600;
79 var candidates = [];
80 var candidate_start;
81
82 for ( candidate_start = 0; candidate_start <= 23 * 3600; candidate_start += 1800 ) {
83 if ( candidate_start >= preferred_start ) {
84 candidates.push( candidate_start );
85 }
86 }
87 for ( candidate_start = 0; candidate_start < preferred_start && candidate_start <= 23 * 3600; candidate_start += 1800 ) {
88 candidates.push( candidate_start );
89 }
90
91 candidate_start = null;
92 candidates.some( function ( proposed_start ) {
93 var proposed_end = proposed_start + 3600;
94 var overlaps = sorted_intervals.some( function ( interval ) {
95 return proposed_start < interval.end_second && proposed_end > interval.start_second;
96 } );
97
98 if ( proposed_end <= 86400 && ! overlaps ) {
99 candidate_start = proposed_start;
100 return true;
101 }
102 return false;
103 } );
104
105 return null === candidate_start ? null : {
106 start_second: candidate_start,
107 end_second: candidate_start + 3600
108 };
109 }
110
111 /**
112 * Render all weekday rows from the data-only Working Hours draft.
113 *
114 * @return {void}
115 */
116 function render_working_hours_editor() {
117 if ( ! editor || ! editor.template ) {
118 return;
119 }
120
121 editor.node.classList.toggle( 'is-disabled', ! editor.enabled );
122 if ( editor.enabled_toggle ) {
123 editor.enabled_toggle.checked = editor.enabled;
124 }
125 if ( editor.schedule_controls ) {
126 editor.schedule_controls.disabled = ! editor.enabled;
127 }
128
129 editor.node.querySelectorAll( '[data-wpbc-working-day]' ).forEach( function ( day_node ) {
130 var day_number = parseInt( day_node.dataset.wpbcWorkingDay, 10 );
131 var day_label = day_node.dataset.dayLabel || '';
132 var intervals = editor.weekdays[ day_number ] || [];
133 var is_enabled = 0 < intervals.length;
134 var toggle = day_node.querySelector( '[data-wpbc-working-day-toggle]' );
135 var interval_container = day_node.querySelector( '[data-wpbc-working-intervals]' );
136 var unavailable = day_node.querySelector( '[data-wpbc-working-unavailable]' );
137 var add_button = day_node.querySelector( '[data-wpbc-add-working-interval]' );
138
139 day_node.classList.toggle( 'is-enabled', is_enabled );
140 if ( toggle ) {
141 toggle.checked = is_enabled;
142 }
143 if ( interval_container ) {
144 interval_container.hidden = ! is_enabled;
145 interval_container.textContent = '';
146 intervals.forEach( function ( interval, interval_index ) {
147 var interval_node = editor.template.content.firstElementChild.cloneNode( true );
148 var start_select = interval_node.querySelector( '[data-wpbc-working-start]' );
149 var end_select = interval_node.querySelector( '[data-wpbc-working-end]' );
150 var start_label = interval_node.querySelector( '[data-wpbc-working-start-label]' );
151 var end_label = interval_node.querySelector( '[data-wpbc-working-end-label]' );
152 var remove_button = interval_node.querySelector( '[data-wpbc-remove-working-interval]' );
153 var start_id = 'wpbc-working-' + day_number + '-' + interval_index + '-start';
154 var end_id = 'wpbc-working-' + day_number + '-' + interval_index + '-end';
155
156 interval_node.dataset.intervalIndex = String( interval_index );
157 start_select.id = start_id;
158 start_select.value = String( interval.start_second );
159 end_select.id = end_id;
160 end_select.value = String( interval.end_second );
161 start_label.htmlFor = start_id;
162 end_label.htmlFor = end_id;
163 remove_button.setAttribute( 'aria-label', get_message( 'remove_working_interval', 'Remove %s working interval' ).replace( '%s', day_label ) );
164 interval_container.appendChild( interval_node );
165 } );
166 }
167 if ( unavailable ) {
168 unavailable.hidden = is_enabled;
169 }
170 if ( add_button ) {
171 add_button.hidden = ! is_enabled;
172 add_button.disabled = intervals.length >= editor.max_intervals;
173 }
174 } );
175
176 sync_working_hours_draft();
177 }
178
179 /**
180 * Restore focus after an interval render replaces its controls.
181 *
182 * @param {number} day_number Weekday number using the canonical 0-6 shape.
183 * @param {number} interval_index Preferred interval index after render.
184 * @return {void}
185 */
186 function focus_working_hours_control( day_number, interval_index ) {
187 var day_node;
188 var interval_node;
189 var focus_target;
190
191 if ( ! editor ) {
192 return;
193 }
194
195 day_node = editor.node.querySelector( '[data-wpbc-working-day="' + day_number + '"]' );
196 if ( ! day_node ) {
197 return;
198 }
199
200 interval_node = day_node.querySelector( '[data-interval-index="' + interval_index + '"]' );
201 focus_target = interval_node
202 ? interval_node.querySelector( '[data-wpbc-working-start]' )
203 : day_node.querySelector( '[data-wpbc-working-day-toggle]' );
204 if ( focus_target ) {
205 focus_target.focus();
206 }
207 }
208
209 /**
210 * Keep copy targets valid when the source day changes.
211 *
212 * @return {void}
213 */
214 function refresh_copy_targets() {
215 var source_day;
216
217 if ( ! editor || ! editor.copy_source ) {
218 return;
219 }
220
221 source_day = editor.copy_source.value;
222 editor.node.querySelectorAll( '[data-wpbc-copy-target]' ).forEach( function ( target ) {
223 var is_source = target.value === source_day;
224 target.disabled = is_source;
225 if ( is_source ) {
226 target.checked = false;
227 }
228 } );
229 }
230
231 /**
232 * Open the accessible schedule-copy controls.
233 *
234 * @return {void}
235 */
236 function open_copy_panel() {
237 if ( ! editor || ! editor.copy_panel || ! editor.copy_toggle ) {
238 return;
239 }
240
241 editor.copy_panel.hidden = false;
242 editor.copy_toggle.setAttribute( 'aria-expanded', 'true' );
243 refresh_copy_targets();
244 editor.copy_source.focus();
245 }
246
247 /**
248 * Close schedule-copy controls and optionally restore trigger focus.
249 *
250 * @param {boolean} restore_focus Whether trigger focus should be restored.
251 * @return {void}
252 */
253 function close_copy_panel( restore_focus ) {
254 if ( ! editor || ! editor.copy_panel || ! editor.copy_toggle ) {
255 return;
256 }
257
258 editor.copy_panel.hidden = true;
259 editor.copy_toggle.setAttribute( 'aria-expanded', 'false' );
260 if ( restore_focus ) {
261 editor.copy_toggle.focus();
262 }
263 }
264
265 /**
266 * Copy one source schedule to all selected target weekdays.
267 *
268 * Empty source schedules are valid and make the selected targets
269 * unavailable, matching the visible source state.
270 *
271 * @return {void}
272 */
273 function apply_schedule_copy() {
274 var source_day = parseInt( editor.copy_source.value, 10 );
275 var targets = Array.prototype.slice.call( editor.node.querySelectorAll( '[data-wpbc-copy-target]:checked:not(:disabled)' ) );
276 var source_intervals = editor.weekdays[ source_day ] || [];
277 var first_available_target;
278
279 if ( ! targets.length ) {
280 shell_api.set_status( get_message( 'copy_target_required', 'Select at least one target day.' ) );
281 first_available_target = editor.node.querySelector( '[data-wpbc-copy-target]:not(:disabled)' );
282 if ( first_available_target ) {
283 first_available_target.focus();
284 }
285 return;
286 }
287
288 targets.forEach( function ( target ) {
289 editor.weekdays[ parseInt( target.value, 10 ) ] = clone_intervals( source_intervals );
290 } );
291 render_working_hours_editor();
292 shell_api.clear_field_error( 'working_hours' );
293 shell_api.set_status( get_message( 'schedule_copied', 'The working-hours schedule was copied to the selected days.' ) );
294 close_copy_panel( true );
295 }
296
297 /**
298 * Handle Working Hours button actions.
299 *
300 * @param {MouseEvent} event Delegated click event.
301 * @return {void}
302 */
303 function handle_click( event ) {
304 var add_button = event.target.closest( '[data-wpbc-add-working-interval]' );
305 var remove_button = event.target.closest( '[data-wpbc-remove-working-interval]' );
306
307 if ( event.target.closest( '[data-wpbc-copy-schedule-toggle]' ) ) {
308 event.preventDefault();
309 if ( editor.copy_panel.hidden ) {
310 open_copy_panel();
311 } else {
312 close_copy_panel( true );
313 }
314 return;
315 }
316 if ( event.target.closest( '[data-wpbc-copy-schedule-cancel]' ) ) {
317 event.preventDefault();
318 close_copy_panel( true );
319 return;
320 }
321 if ( event.target.closest( '[data-wpbc-copy-schedule-apply]' ) ) {
322 event.preventDefault();
323 apply_schedule_copy();
324 return;
325 }
326
327 if ( add_button ) {
328 var add_day_node = add_button.closest( '[data-wpbc-working-day]' );
329 var add_day_number = parseInt( add_day_node.dataset.wpbcWorkingDay, 10 );
330 var proposal = find_interval_proposal( editor.weekdays[ add_day_number ] || [] );
331
332 event.preventDefault();
333 if ( ! proposal || editor.weekdays[ add_day_number ].length >= editor.max_intervals ) {
334 shell_api.set_status( get_message( 'working_hours_limit', 'No additional one-hour interval is available for this day.' ) );
335 return;
336 }
337 editor.weekdays[ add_day_number ].push( proposal );
338 render_working_hours_editor();
339 focus_working_hours_control( add_day_number, editor.weekdays[ add_day_number ].length - 1 );
340 shell_api.clear_field_error( 'working_hours' );
341 return;
342 }
343
344 if ( remove_button ) {
345 var remove_day_node = remove_button.closest( '[data-wpbc-working-day]' );
346 var remove_interval_node = remove_button.closest( '[data-interval-index]' );
347 var remove_day_number = parseInt( remove_day_node.dataset.wpbcWorkingDay, 10 );
348 var remove_interval_index = parseInt( remove_interval_node.dataset.intervalIndex, 10 );
349
350 event.preventDefault();
351 editor.weekdays[ remove_day_number ].splice( remove_interval_index, 1 );
352 render_working_hours_editor();
353 focus_working_hours_control( remove_day_number, Math.min( remove_interval_index, editor.weekdays[ remove_day_number ].length - 1 ) );
354 shell_api.clear_field_error( 'working_hours' );
355 }
356 }
357
358 /**
359 * Handle weekday, interval, and copy-source changes.
360 *
361 * @param {Event} event Delegated change event.
362 * @return {void}
363 */
364 function handle_change( event ) {
365 if ( event.target.matches( '[data-wpbc-working-hours-enabled]' ) ) {
366 editor.enabled = event.target.checked;
367 if ( ! editor.enabled ) {
368 close_copy_panel( false );
369 }
370 render_working_hours_editor();
371 shell_api.clear_field_error( 'working_hours' );
372 return;
373 }
374
375 if ( event.target.matches( '[data-wpbc-copy-source]' ) ) {
376 refresh_copy_targets();
377 return;
378 }
379
380 if ( event.target.matches( '[data-wpbc-working-day-toggle]' ) ) {
381 var toggle_day_node = event.target.closest( '[data-wpbc-working-day]' );
382 var toggle_day_number = parseInt( toggle_day_node.dataset.wpbcWorkingDay, 10 );
383
384 editor.weekdays[ toggle_day_number ] = event.target.checked ? [ find_interval_proposal( [] ) ] : [];
385 render_working_hours_editor();
386 shell_api.clear_field_error( 'working_hours' );
387 return;
388 }
389
390 if ( event.target.matches( '[data-wpbc-working-start], [data-wpbc-working-end]' ) ) {
391 var changed_day_node = event.target.closest( '[data-wpbc-working-day]' );
392 var changed_interval_node = event.target.closest( '[data-interval-index]' );
393 var changed_day_number = parseInt( changed_day_node.dataset.wpbcWorkingDay, 10 );
394 var changed_interval_index = parseInt( changed_interval_node.dataset.intervalIndex, 10 );
395 var changed_interval = editor.weekdays[ changed_day_number ][ changed_interval_index ];
396
397 if ( event.target.matches( '[data-wpbc-working-start]' ) ) {
398 changed_interval.start_second = parseInt( event.target.value, 10 );
399 } else {
400 changed_interval.end_second = parseInt( event.target.value, 10 );
401 }
402 sync_working_hours_draft();
403 shell_api.clear_field_error( 'working_hours' );
404 }
405 }
406
407 /**
408 * Close the schedule-copy panel with Escape while focus is inside it.
409 *
410 * @param {KeyboardEvent} event Keydown event.
411 * @return {void}
412 */
413 function handle_keydown( event ) {
414 if ( 'Escape' === event.key && editor.copy_panel && ! editor.copy_panel.hidden && editor.copy_panel.contains( event.target ) ) {
415 event.preventDefault();
416 close_copy_panel( true );
417 }
418 }
419
420 /**
421 * Initialize this module against shell-provided services.
422 *
423 * @param {Object} registered_shell_api Shared wizard adapter services.
424 * @return {void}
425 */
426 function initialize( registered_shell_api ) {
427 var parsed_working_hours;
428 var weekdays = {};
429 var editor_node;
430 var transport;
431
432 shell_api = registered_shell_api;
433 root = shell_api.root;
434 editor_node = root.querySelector( '[data-wpbc-working-hours-editor]' );
435 transport = root.querySelector( '[data-wpbc-working-hours-draft]' );
436 if ( ! editor_node || ! transport ) {
437 return;
438 }
439
440 try {
441 parsed_working_hours = JSON.parse( transport.value || '{}' );
442 } catch ( parse_error ) {
443 parsed_working_hours = {};
444 }
445
446 [ 0, 1, 2, 3, 4, 5, 6 ].forEach( function ( day_number ) {
447 var raw_intervals = parsed_working_hours.weekdays && Array.isArray( parsed_working_hours.weekdays[ day_number ] ) ? parsed_working_hours.weekdays[ day_number ] : [];
448
449 weekdays[ day_number ] = raw_intervals.map( function ( interval ) {
450 return {
451 start_second: parseInt( interval.start_second, 10 ),
452 end_second: parseInt( interval.end_second, 10 )
453 };
454 } ).filter( function ( interval ) {
455 return isFinite( interval.start_second ) && Math.floor( interval.start_second ) === interval.start_second && isFinite( interval.end_second ) && Math.floor( interval.end_second ) === interval.end_second;
456 } );
457 } );
458
459 editor = {
460 node: editor_node,
461 transport: transport,
462 template: root.querySelector( '[data-wpbc-working-interval-template]' ),
463 enabled: 'Off' !== parsed_working_hours.enabled,
464 enabled_toggle: editor_node.querySelector( '[data-wpbc-working-hours-enabled]' ),
465 schedule_controls: editor_node.querySelector( '[data-wpbc-working-hours-controls]' ),
466 weekdays: weekdays,
467 max_intervals: parseInt( transport.dataset.maxIntervals || '8', 10 ),
468 copy_toggle: editor_node.querySelector( '[data-wpbc-copy-schedule-toggle]' ),
469 copy_panel: editor_node.querySelector( '[data-wpbc-copy-schedule-panel]' ),
470 copy_source: editor_node.querySelector( '[data-wpbc-copy-source]' )
471 };
472
473 editor.node.addEventListener( 'click', handle_click );
474 editor.node.addEventListener( 'change', handle_change );
475 editor.node.addEventListener( 'keydown', handle_keydown );
476 refresh_copy_targets();
477 render_working_hours_editor();
478 }
479
480 /**
481 * Validate interval order and overlap before forward navigation.
482 *
483 * @return {HTMLElement|null} First invalid control, or null.
484 */
485 function validate() {
486 var first_invalid = null;
487 var enabled_interval_count = 0;
488
489 if ( ! editor ) {
490 return null;
491 }
492 if ( ! editor.enabled ) {
493 shell_api.clear_field_error( 'working_hours' );
494 sync_working_hours_draft();
495 return null;
496 }
497
498 editor.node.querySelectorAll( '[data-wpbc-working-start], [data-wpbc-working-end]' ).forEach( function ( control ) {
499 control.removeAttribute( 'aria-invalid' );
500 } );
501
502 [ 0, 1, 2, 3, 4, 5, 6 ].some( function ( day_number ) {
503 var intervals = editor.weekdays[ day_number ] || [];
504 var sorted_intervals = intervals.slice().sort( function ( first_interval, second_interval ) {
505 return first_interval.start_second - second_interval.start_second;
506 } );
507 var previous_end = -1;
508 var day_node = editor.node.querySelector( '[data-wpbc-working-day="' + day_number + '"]' );
509
510 enabled_interval_count += intervals.length;
511 return sorted_intervals.some( function ( interval ) {
512 var interval_index = intervals.indexOf( interval );
513 var interval_node = day_node && day_node.querySelector( '[data-interval-index="' + interval_index + '"]' );
514 var invalid_range = ! isFinite( interval.start_second ) || Math.floor( interval.start_second ) !== interval.start_second || ! isFinite( interval.end_second ) || Math.floor( interval.end_second ) !== interval.end_second || interval.start_second < 0 || interval.end_second > 86400 || interval.start_second >= interval.end_second;
515 var overlaps = interval.start_second < previous_end;
516
517 previous_end = Math.max( previous_end, interval.end_second );
518 if ( invalid_range || overlaps ) {
519 first_invalid = interval_node ? interval_node.querySelector( invalid_range ? '[data-wpbc-working-end]' : '[data-wpbc-working-start]' ) : day_node.querySelector( '[data-wpbc-working-day-toggle]' );
520 if ( first_invalid ) {
521 first_invalid.setAttribute( 'aria-invalid', 'true' );
522 }
523 shell_api.set_field_error( 'working_hours', invalid_range ? get_message( 'working_hours_invalid', 'Every working interval must end after it starts.' ) : get_message( 'working_hours_overlap', 'Working intervals on the same day cannot overlap or repeat.' ) );
524 return true;
525 }
526 return false;
527 } );
528 } );
529
530 if ( ! first_invalid && 0 === enabled_interval_count ) {
531 first_invalid = shell_api.set_field_error( 'working_hours', get_message( 'working_hours_required', 'Enable at least one weekday and add its working hours.' ) );
532 }
533
534 sync_working_hours_draft();
535 return first_invalid;
536 }
537
538 return {
539 initialize: initialize,
540 sync: sync_working_hours_draft,
541 validate: validate
542 };
543 }
544
545 window.wpbc_setup_editor_modules = window.wpbc_setup_editor_modules || {};
546 window.wpbc_setup_editor_modules.working_hours = {
547 create: create_working_hours_adapter
548 };
549
550 if ( window.wpbc_setup_wizard_api && 'function' === typeof window.wpbc_setup_wizard_api.register_step_adapter ) {
551 window.wpbc_setup_wizard_api.register_step_adapter( create_working_hours_adapter( module_config ) );
552 }
553 }( window, document ) );
554