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-days-off / _src / step-days-off.js

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

973 lines 33.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Coordinate the reusable Setup Wizard Days Off editor.
3 *
4 * The browser selects a bounded range with Booking Calendar's standard
5 * datepicker. Add/remove actions update a bounded browser proposal only. The
6 * shared Save boundary remains authoritative for Resource access, stale
7 * revisions, validation, and canonical Days Availability writes.
8 *
9 * @package Booking Calendar
10 */
11 ( function ( $, window, document ) {
12 'use strict';
13
14 var module_config = window.wpbc_setup_wizard_days_off || { i18n: {} };
15 var request_sequence = 0;
16
17 /**
18 * Create the Days Off adapter accepted by the shared Setup Wizard shell.
19 *
20 * @param {Object} config Authorized endpoint and translated labels.
21 * @return {Object} Step adapter.
22 */
23 function create_days_off_adapter( config ) {
24 var shell_api = null;
25 var editor = null;
26 var state = {};
27 var all_unavailable_dates = {};
28 var base_revision = '';
29 var selected_first_date = '';
30 var selected_last_date = '';
31
32 /**
33 * Return one translated label with a safe fallback.
34 *
35 * @param {string} key Translation key.
36 * @param {string} fallback Fallback text.
37 * @return {string} Translation or fallback.
38 */
39 function get_message( key, fallback ) {
40 return config.i18n && config.i18n[ key ] ? String( config.i18n[ key ] ) : fallback;
41 }
42
43 /**
44 * Restore keyboard focus without moving the wizard or browser viewport.
45 *
46 * Re-rendering the unavailable-range list removes the activated delete
47 * button. The replacement focus target must remain accessible without
48 * making the independently scrolling wizard jump to its top.
49 *
50 * @param {HTMLElement|null} focus_target Remaining control to receive focus.
51 * @return {void}
52 */
53 function focus_without_scrolling( focus_target ) {
54 var scroll_container = editor && editor.node ? editor.node.closest( '.wpbc_setup_wizard__main' ) : null;
55 var container_scroll_top = scroll_container ? scroll_container.scrollTop : 0;
56 var container_scroll_left = scroll_container ? scroll_container.scrollLeft : 0;
57 var window_scroll_x = window.pageXOffset || document.documentElement.scrollLeft || 0;
58 var window_scroll_y = window.pageYOffset || document.documentElement.scrollTop || 0;
59
60 if ( ! focus_target || focus_target.disabled || ! document.body.contains( focus_target ) ) {
61 return;
62 }
63
64 try {
65 focus_target.focus( { preventScroll: true } );
66 } catch ( focus_error ) {
67 focus_target.focus();
68 }
69
70 if ( scroll_container ) {
71 scroll_container.scrollTop = container_scroll_top;
72 scroll_container.scrollLeft = container_scroll_left;
73 }
74 if ( window.scrollTo && ( window_scroll_x !== window.pageXOffset || window_scroll_y !== window.pageYOffset ) ) {
75 window.scrollTo( window_scroll_x, window_scroll_y );
76 }
77 }
78
79 /**
80 * Parse one exact ISO calendar date without UTC conversion drift.
81 *
82 * @param {string} iso_date Date in YYYY-MM-DD format.
83 * @return {Date|null} Local-midnight Date or null.
84 */
85 function parse_iso_date( iso_date ) {
86 var parts = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec( String( iso_date || '' ) );
87 var parsed_date;
88
89 if ( ! parts ) {
90 return null;
91 }
92
93 parsed_date = new Date( parseInt( parts[ 1 ], 10 ), parseInt( parts[ 2 ], 10 ) - 1, parseInt( parts[ 3 ], 10 ) );
94 return parsed_date.getFullYear() === parseInt( parts[ 1 ], 10 ) && parsed_date.getMonth() === parseInt( parts[ 2 ], 10 ) - 1 && parsed_date.getDate() === parseInt( parts[ 3 ], 10 ) ? parsed_date : null;
95 }
96
97 /**
98 * Format a Date as canonical YYYY-MM-DD.
99 *
100 * @param {Date} date Local Date instance.
101 * @return {string} ISO date.
102 */
103 function format_iso_date( date ) {
104 var month = ( '0' + String( date.getMonth() + 1 ) ).slice( -2 );
105 var day = ( '0' + String( date.getDate() ) ).slice( -2 );
106
107 return String( date.getFullYear() ) + '-' + month + '-' + day;
108 }
109
110 /**
111 * Format one ISO date for visible labels while preserving ISO transport.
112 *
113 * @param {string} iso_date Canonical date.
114 * @return {string} Localized short label.
115 */
116 function format_date_label( iso_date ) {
117 var date = parse_iso_date( iso_date );
118 var locale = String( config.locale || '' ).replace( '_', '-' );
119
120 if ( ! date || ! window.Intl || ! window.Intl.DateTimeFormat ) {
121 return iso_date;
122 }
123
124 try {
125 return new window.Intl.DateTimeFormat( locale || undefined, {
126 year: 'numeric',
127 month: 'short',
128 day: 'numeric'
129 } ).format( date );
130 } catch ( locale_error ) {
131 return iso_date;
132 }
133 }
134
135 /**
136 * Return the inclusive number of selected calendar days.
137 *
138 * @return {number} Inclusive day count, or zero for an invalid range.
139 */
140 function get_selected_day_count() {
141 var first_date = parse_iso_date( selected_first_date );
142 var last_date = parse_iso_date( selected_last_date );
143
144 if ( ! first_date || ! last_date || last_date < first_date ) {
145 return 0;
146 }
147
148 return Math.round( ( last_date.getTime() - first_date.getTime() ) / 86400000 ) + 1;
149 }
150
151 /**
152 * Normalize server state to the data-only fields used by this adapter.
153 *
154 * @param {Object} next_state Authorized server state.
155 * @return {Object} Safe normalized state.
156 */
157 function normalize_state( next_state ) {
158 next_state = next_state && 'object' === typeof next_state ? next_state : {};
159
160 return {
161 can_manage: true === next_state.can_manage,
162 has_resources: true === next_state.has_resources,
163 scope: 'string' === typeof next_state.scope ? next_state.scope : 'all',
164 resources: Array.isArray( next_state.resources ) ? next_state.resources : [],
165 unavailable_dates: next_state.unavailable_dates && 'object' === typeof next_state.unavailable_dates ? next_state.unavailable_dates : {},
166 date_states: next_state.date_states && 'object' === typeof next_state.date_states ? next_state.date_states : {},
167 booking_date_states: next_state.booking_date_states && 'object' === typeof next_state.booking_date_states ? next_state.booking_date_states : {},
168 ranges: Array.isArray( next_state.ranges ) ? next_state.ranges : [],
169 revision: 'string' === typeof next_state.revision ? next_state.revision : '',
170 today: 'string' === typeof next_state.today ? next_state.today : '',
171 is_aggregate_scope: true === next_state.is_aggregate_scope,
172 default_first_date: 'string' === typeof next_state.default_first_date ? next_state.default_first_date : '',
173 default_last_date: 'string' === typeof next_state.default_last_date ? next_state.default_last_date : '',
174 first_day: Math.max( 0, Math.min( 6, parseInt( next_state.first_day || 0, 10 ) || 0 ) ),
175 max_range_days: Math.max( 1, parseInt( next_state.max_range_days || 366, 10 ) || 366 )
176 };
177 }
178
179 /**
180 * Normalize the complete staged map without sharing mutable arrays.
181 *
182 * @param {Object} dates_by_resource Dates keyed by Resource ID.
183 * @return {Object} Safe cloned date map.
184 */
185 function normalize_unavailable_dates( dates_by_resource ) {
186 var normalized = {};
187
188 state.resources.forEach( function ( resource ) {
189 var resource_id = String( parseInt( resource.id, 10 ) || 0 );
190 var dates = dates_by_resource && Array.isArray( dates_by_resource[ resource_id ] ) ? dates_by_resource[ resource_id ] : [];
191
192 if ( '0' === resource_id ) {
193 return;
194 }
195 normalized[ resource_id ] = dates.filter( function ( date ) {
196 return null !== parse_iso_date( date );
197 } ).filter( function ( date, index, source ) {
198 return source.indexOf( date ) === index;
199 } ).sort();
200 } );
201
202 return normalized;
203 }
204
205 /**
206 * Return Resource IDs affected by one visible scope.
207 *
208 * @param {string} scope Resource ID or `all`.
209 * @return {string[]} Authorized Resource IDs.
210 */
211 function get_scope_resource_ids( scope ) {
212 var resource_ids = state.resources.map( function ( resource ) {
213 return String( parseInt( resource.id, 10 ) || 0 );
214 } ).filter( function ( resource_id ) {
215 return '0' !== resource_id;
216 } );
217
218 return 'all' === scope ? resource_ids : resource_ids.filter( function ( resource_id ) {
219 return resource_id === scope;
220 } );
221 }
222
223 /**
224 * Expand a bounded inclusive range into ISO dates.
225 *
226 * @param {string} first_date First ISO date.
227 * @param {string} last_date Last ISO date.
228 * @return {string[]} Inclusive date list.
229 */
230 function expand_date_range( first_date, last_date ) {
231 var cursor = parse_iso_date( first_date );
232 var end = parse_iso_date( last_date );
233 var dates = [];
234
235 while ( cursor && end && cursor <= end && dates.length < state.max_range_days ) {
236 dates.push( format_iso_date( cursor ) );
237 cursor.setDate( cursor.getDate() + 1 );
238 }
239
240 return dates;
241 }
242
243 /**
244 * Collapse ordered ISO dates into inclusive range records.
245 *
246 * @param {string[]} dates ISO dates.
247 * @return {Object[]} Consecutive ranges.
248 */
249 function collapse_dates( dates ) {
250 var ranges = [];
251 var first_date = '';
252 var last_date = '';
253
254 dates.slice().sort().forEach( function ( date ) {
255 var expected_next;
256
257 if ( ! first_date ) {
258 first_date = date;
259 last_date = date;
260 return;
261 }
262 expected_next = parse_iso_date( last_date );
263 expected_next.setDate( expected_next.getDate() + 1 );
264 if ( format_iso_date( expected_next ) === date ) {
265 last_date = date;
266 return;
267 }
268 ranges.push( { first_date: first_date, last_date: last_date } );
269 first_date = date;
270 last_date = date;
271 } );
272 if ( first_date ) {
273 ranges.push( { first_date: first_date, last_date: last_date } );
274 }
275
276 return ranges;
277 }
278
279 /**
280 * Build display ranges from the complete staged proposal.
281 *
282 * @param {string[]} resource_ids Scoped Resource IDs.
283 * @param {string} scope Current scope.
284 * @return {Object[]} Display range records.
285 */
286 function build_range_records( resource_ids, scope ) {
287 var range_counts = {};
288 var resource_ranges = {};
289 var resource_labels = {};
290 var shared_added = {};
291 var records = [];
292
293 state.resources.forEach( function ( resource ) {
294 resource_labels[ String( resource.id ) ] = String( resource.label || '' );
295 } );
296 resource_ids.forEach( function ( resource_id ) {
297 resource_ranges[ resource_id ] = collapse_dates( all_unavailable_dates[ resource_id ] || [] );
298 resource_ranges[ resource_id ].forEach( function ( range ) {
299 var key = range.first_date + '|' + range.last_date;
300
301 range_counts[ key ] = ( range_counts[ key ] || 0 ) + 1;
302 } );
303 } );
304 resource_ids.forEach( function ( resource_id ) {
305 resource_ranges[ resource_id ].forEach( function ( range ) {
306 var key = range.first_date + '|' + range.last_date;
307 var is_shared = 1 < resource_ids.length && range_counts[ key ] === resource_ids.length;
308 var range_scope = is_shared ? 'all' : ( 1 === resource_ids.length ? scope : resource_id );
309
310 if ( is_shared && shared_added[ key ] ) {
311 return;
312 }
313 records.push( {
314 first_date: range.first_date,
315 last_date: range.last_date,
316 label: format_date_label( range.first_date ) + ( range.first_date === range.last_date ? '' : ' \u2013 ' + format_date_label( range.last_date ) ),
317 scope: range_scope,
318 scope_label: 'all' === range_scope ? get_message( 'all_booking_items', 'All booking items' ) : resource_labels[ resource_id ]
319 } );
320 if ( is_shared ) {
321 shared_added[ key ] = true;
322 }
323 } );
324 } );
325
326 return records.sort( function ( first_range, second_range ) {
327 return ( first_range.first_date + '|' + first_range.scope ).localeCompare( second_range.first_date + '|' + second_range.scope );
328 } );
329 }
330
331 /**
332 * Synchronize the complete staged proposal into the shared wizard form.
333 *
334 * @return {void}
335 */
336 function sync_staged_value() {
337 if ( editor && editor.value ) {
338 editor.value.value = JSON.stringify( {
339 revision: base_revision,
340 unavailable_dates: all_unavailable_dates
341 } );
342 }
343 }
344
345 /**
346 * Rebuild calendar and list presentation from the browser proposal.
347 *
348 * @param {string} requested_scope Current authorized scope.
349 * @return {void}
350 */
351 function rebuild_staged_state( requested_scope ) {
352 var resource_ids = get_scope_resource_ids( requested_scope );
353 var date_counts = {};
354
355 state.scope = requested_scope;
356 state.unavailable_dates = {};
357 resource_ids.forEach( function ( resource_id ) {
358 state.unavailable_dates[ resource_id ] = ( all_unavailable_dates[ resource_id ] || [] ).slice();
359 state.unavailable_dates[ resource_id ].forEach( function ( date ) {
360 date_counts[ date ] = ( date_counts[ date ] || 0 ) + 1;
361 } );
362 } );
363 state.date_states = {};
364 Object.keys( date_counts ).forEach( function ( date ) {
365 state.date_states[ date ] = date_counts[ date ] === resource_ids.length ? 'unavailable' : 'partially_available';
366 } );
367 state.ranges = build_range_records( resource_ids, requested_scope );
368 state.is_aggregate_scope = 'all' === requested_scope && 1 < resource_ids.length;
369 apply_scope_value( requested_scope );
370 if ( editor.aggregate_legend ) {
371 editor.aggregate_legend.hidden = ! state.is_aggregate_scope;
372 }
373 if ( editor.resource_legend ) {
374 editor.resource_legend.hidden = state.is_aggregate_scope;
375 }
376 sync_staged_value();
377 render_ranges();
378 refresh_calendar();
379 }
380
381 /**
382 * Parse the hex-escaped initial state emitted by the domain template.
383 *
384 * @param {HTMLElement} editor_node Editor root.
385 * @return {Object} Parsed state or an empty object.
386 */
387 function read_initial_state( editor_node ) {
388 var state_node = editor_node.querySelector( '[data-wpbc-days-off-state]' );
389
390 if ( ! state_node ) {
391 return {};
392 }
393
394 try {
395 return JSON.parse( state_node.textContent || '{}' );
396 } catch ( parse_error ) {
397 return {};
398 }
399 }
400
401 /**
402 * Return native Booking Calendar classes for one scoped calendar date.
403 *
404 * @param {Date} date Calendar date.
405 * Availability remains selectable on this editor. Canonical booked-date
406 * states are read-only context and follow the same full-day/time-slot class
407 * vocabulary as the native Days Availability calendar.
408 *
409 * @return {Object} Native day classes and an accessible status title.
410 */
411 function get_calendar_day_presentation( date ) {
412 var iso_date = format_iso_date( date );
413 var state_class = String( state.date_states[ iso_date ] || '' );
414 var booking_state = String( state.booking_date_states[ iso_date ] || '' );
415 var native_date_class = 'cal4date-' + String( date.getMonth() + 1 ) + '-' + String( date.getDate() ) + '-' + String( date.getFullYear() );
416 var css_classes = native_date_class + ' date_available';
417 var status_labels = [];
418
419 if ( 'unavailable' === state_class ) {
420 return {
421 css_classes: native_date_class + ' date_user_unavailable resource_unavailable wpbc_setup_wizard__calendar-date--unavailable',
422 status_title: get_message( 'unavailable', 'Unavailable' )
423 };
424 }
425 if ( 'partially_available' === state_class ) {
426 css_classes += ' wpbc_resources_partially_unavailable wpbc_setup_wizard__calendar-date--partially-available';
427 status_labels.push( get_message( 'some_resources_unavailable', 'Some booking items unavailable' ) );
428 }
429
430 if ( 'resources_have_bookings' === booking_state ) {
431 css_classes += ' wpbc_resources_have_bookings';
432 status_labels.push( get_message( 'resources_have_bookings', 'Bookings exist for one or more booking items' ) );
433 } else if ( 'approved_full' === booking_state ) {
434 css_classes = native_date_class + ' date_approved full_day_booking';
435 status_labels.push( get_message( 'approved_booking', 'Approved booking' ) );
436 } else if ( 'pending_full' === booking_state ) {
437 css_classes = native_date_class + ' date2approve full_day_booking';
438 status_labels.push( get_message( 'pending_booking', 'Pending booking' ) );
439 } else if ( 'approved_partial' === booking_state ) {
440 css_classes += ' date_approved timespartly times_clock';
441 status_labels.push( get_message( 'partially_booked', 'Partially booked' ) );
442 } else if ( 'pending_partial' === booking_state ) {
443 css_classes += ' date2approve timespartly times_clock';
444 status_labels.push( get_message( 'partially_booked_pending', 'Partially booked; pending booking' ) );
445 }
446
447 return {
448 css_classes: css_classes,
449 status_title: status_labels.join( '. ' )
450 };
451 }
452
453 /**
454 * Refresh the visible selected range and action state.
455 *
456 * @return {void}
457 */
458 function render_selection_summary() {
459 var day_count = get_selected_day_count();
460 var count_label = 1 === day_count
461 ? get_message( 'one_unavailable_day', '1 unavailable day' )
462 : get_message( 'many_unavailable_days', '%s unavailable days' ).replace( '%s', String( day_count ) );
463
464 editor.first_date.textContent = selected_first_date ? format_date_label( selected_first_date ) : '';
465 editor.last_date.textContent = selected_last_date ? format_date_label( selected_last_date ) : '';
466 editor.summary_range.textContent = day_count ? format_date_label( selected_first_date ) + ( selected_first_date === selected_last_date ? '' : ' \u2013 ' + format_date_label( selected_last_date ) ) : '';
467 editor.summary_count.textContent = day_count ? count_label : '';
468 editor.add_button.disabled = ! day_count || day_count > state.max_range_days;
469 }
470
471 /**
472 * Create one accessible range row using text-only DOM APIs.
473 *
474 * @param {Object} range Authorized range record.
475 * @return {HTMLElement|null} List row or null.
476 */
477 function create_range_row( range ) {
478 var row;
479 var date_label;
480 var scope_label;
481 var remove_button;
482 var remove_icon;
483
484 if ( ! range || 'string' !== typeof range.first_date || 'string' !== typeof range.last_date || 'string' !== typeof range.scope ) {
485 return null;
486 }
487
488 row = document.createElement( 'li' );
489 date_label = document.createElement( 'span' );
490 scope_label = document.createElement( 'span' );
491 remove_button = document.createElement( 'button' );
492 remove_icon = document.createElement( 'i' );
493
494 date_label.textContent = String( range.label || range.first_date );
495 scope_label.textContent = String( range.scope_label || '' );
496 remove_button.type = 'button';
497 remove_button.className = 'wpbc_setup_wizard__days-off-remove';
498 remove_button.setAttribute( 'data-wpbc-days-off-remove', '' );
499 remove_button.setAttribute( 'data-first-date', range.first_date );
500 remove_button.setAttribute( 'data-last-date', range.last_date );
501 remove_button.setAttribute( 'data-scope', range.scope );
502 remove_button.setAttribute( 'aria-label', get_message( 'remove_range', 'Remove unavailable date range' ) );
503 remove_icon.className = 'menu_icon icon-1x wpbc_icn_delete_outline';
504 remove_icon.setAttribute( 'aria-hidden', 'true' );
505 remove_button.appendChild( remove_icon );
506 row.appendChild( date_label );
507 row.appendChild( scope_label );
508 row.appendChild( remove_button );
509
510 return row;
511 }
512
513 /**
514 * Replace the upcoming-range list from server-authorized records.
515 *
516 * @return {void}
517 */
518 function render_ranges() {
519 editor.range_list.textContent = '';
520 state.ranges.forEach( function ( range ) {
521 var row = create_range_row( range );
522
523 if ( row ) {
524 editor.range_list.appendChild( row );
525 }
526 } );
527 editor.empty.hidden = 0 < editor.range_list.children.length;
528 }
529
530 /**
531 * Refresh the inline calendar after server state or scope changes.
532 *
533 * @return {void}
534 */
535 function refresh_calendar() {
536 if ( editor.calendar.hasClass( $.datepick.markerClassName ) ) {
537 editor.calendar.datepick( 'refresh' );
538 }
539 }
540
541 /**
542 * Mark only the scoped calendar area as loading.
543 *
544 * Resource changes are read-only and should not cover the complete wizard
545 * with the mutation overlay. The disabled selector also prevents a second
546 * scope change while the authoritative response is pending.
547 *
548 * @param {boolean} is_loading Whether an AJAX scope request is active.
549 * @return {void}
550 */
551 function set_calendar_loading( is_loading ) {
552 editor.calendar_column.classList.toggle( 'is-loading', is_loading );
553 editor.calendar_column.setAttribute( 'aria-busy', is_loading ? 'true' : 'false' );
554 editor.scope.disabled = is_loading;
555 editor.node.querySelectorAll( '[data-wpbc-days-off-remove]' ).forEach( function ( remove_button ) {
556 remove_button.disabled = is_loading;
557 } );
558 if ( is_loading ) {
559 editor.add_button.disabled = true;
560 } else {
561 render_selection_summary();
562 }
563 }
564
565 /**
566 * Select the response scope only when it is still an authorized option.
567 *
568 * @param {string} scope Authorized scope returned by the server.
569 * @return {void}
570 */
571 function apply_scope_value( scope ) {
572 var has_scope = Array.prototype.some.call( editor.scope.options, function ( option ) {
573 return String( option.value ) === scope;
574 } );
575
576 if ( has_scope ) {
577 editor.scope.value = scope;
578 }
579 }
580
581 /**
582 * Apply the initial server state without trusting browser-derived records.
583 *
584 * @param {Object} next_state Authorized response state.
585 * @return {void}
586 */
587 function apply_state( next_state ) {
588 state = normalize_state( next_state );
589 base_revision = state.revision;
590 all_unavailable_dates = normalize_unavailable_dates( state.unavailable_dates );
591 apply_scope_value( state.scope );
592 if ( editor.aggregate_legend ) {
593 editor.aggregate_legend.hidden = ! state.is_aggregate_scope;
594 }
595 if ( editor.resource_legend ) {
596 editor.resource_legend.hidden = state.is_aggregate_scope;
597 }
598 render_ranges();
599 refresh_calendar();
600 sync_staged_value();
601 }
602
603 /**
604 * Clear only the pending browser selection and rebuild the datepicker.
605 *
606 * Canonical unavailable dates and booked-date context stay in server state.
607 * Reinitialization removes the datepicker's private range selection without
608 * mutating either domain.
609 *
610 * @return {void}
611 */
612 function clear_selected_range() {
613 selected_first_date = '';
614 selected_last_date = '';
615 editor.error.textContent = '';
616 if ( editor.calendar.hasClass( $.datepick.markerClassName ) ) {
617 editor.calendar.datepick( 'destroy' );
618 }
619 initialize_calendar();
620 render_selection_summary();
621 }
622
623 /**
624 * Return a safe AJAX error message.
625 *
626 * @param {Object} xhr jQuery request object.
627 * @return {string} Error message.
628 */
629 function get_error_message( xhr ) {
630 if ( xhr && xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message ) {
631 return String( xhr.responseJSON.data.message );
632 }
633
634 return get_message( 'error', 'Date availability could not be updated. Try again.' );
635 }
636
637 /**
638 * Load booking context for one authorized Resource scope.
639 *
640 * Canonical unavailable dates in the response are deliberately ignored so a
641 * scope switch cannot discard the browser proposal waiting for Save.
642 *
643 * @param {string} requested_scope Resource ID or all-resources scope.
644 * @return {void}
645 */
646 function load_scope( requested_scope ) {
647 var previous_scope = state.scope;
648 var sequence;
649 var request_id;
650 var request;
651
652 request_sequence += 1;
653 sequence = request_sequence;
654 request_id = 'days_off_load_' + Date.now() + '_' + sequence;
655 editor.error.textContent = '';
656 set_calendar_loading( true );
657
658 request = $.ajax( {
659 url: config.ajax_url,
660 method: 'POST',
661 dataType: 'json',
662 data: {
663 action: config.load_action,
664 nonce: config.nonce,
665 scope: requested_scope,
666 request_id: request_id
667 }
668 } );
669
670 request.done( function ( response ) {
671 if ( sequence !== request_sequence || ! response || ! response.success || ! response.data || response.data.request_id !== request_id ) {
672 return;
673 }
674
675 var loaded_state = normalize_state( response.data.state );
676
677 state.booking_date_states = loaded_state.booking_date_states;
678 state.today = loaded_state.today || state.today;
679 state.first_day = loaded_state.first_day;
680 state.max_range_days = loaded_state.max_range_days;
681 rebuild_staged_state( loaded_state.scope );
682 } );
683
684 request.fail( function ( xhr ) {
685 var message;
686
687 if ( sequence !== request_sequence ) {
688 return;
689 }
690
691 apply_scope_value( previous_scope );
692 message = get_error_message( xhr );
693 editor.error.textContent = message;
694 shell_api.set_status( message );
695 } );
696
697 request.always( function () {
698 if ( sequence !== request_sequence ) {
699 return;
700 }
701
702 set_calendar_loading( false );
703 editor.scope.focus();
704 } );
705 }
706
707 /**
708 * Stage one explicit add/remove operation for the shared Save boundary.
709 *
710 * @param {string} operation Add or remove.
711 * @param {string} scope Authorized scope value.
712 * @param {string} first_date First ISO date.
713 * @param {string} last_date Last ISO date.
714 * @param {HTMLElement|null} focus_target Focus target restored after completion.
715 * @param {number} remove_index Index of the removed row for focus recovery.
716 * @return {void}
717 */
718 function stage_range( operation, scope, first_date, last_date, focus_target, remove_index ) {
719 var resource_ids = get_scope_resource_ids( scope );
720 var range_dates = expand_date_range( first_date, last_date );
721
722 if ( ! resource_ids.length || ! range_dates.length ) {
723 editor.error.textContent = get_message( 'range_invalid', 'Select a valid unavailable date range.' );
724 return;
725 }
726 resource_ids.forEach( function ( resource_id ) {
727 var current_dates = all_unavailable_dates[ resource_id ] || [];
728
729 if ( 'add' === operation ) {
730 all_unavailable_dates[ resource_id ] = current_dates.concat( range_dates ).filter( function ( date, index, source ) {
731 return source.indexOf( date ) === index;
732 } ).sort();
733 } else {
734 all_unavailable_dates[ resource_id ] = current_dates.filter( function ( date ) {
735 return range_dates.indexOf( date ) === -1;
736 } );
737 }
738 } );
739
740 editor.error.textContent = '';
741 rebuild_staged_state( String( editor.scope.value || 'all' ) );
742 if ( 'add' === operation ) {
743 clear_selected_range();
744 }
745 shell_api.set_status( 'add' === operation ? get_message( 'added', 'The unavailable date range is staged.' ) : get_message( 'removed', 'The unavailable date range removal is staged.' ) );
746 window.setTimeout( function () {
747 var remaining_remove_buttons = Array.prototype.slice.call( editor.node.querySelectorAll( '[data-wpbc-days-off-remove]' ) );
748 var fallback_target = null;
749
750 if ( focus_target && ! focus_target.disabled && document.body.contains( focus_target ) ) {
751 focus_without_scrolling( focus_target );
752 return;
753 }
754 if ( 'remove' === operation && remaining_remove_buttons.length && remove_index >= 0 ) {
755 fallback_target = remaining_remove_buttons[ Math.min( remove_index, remaining_remove_buttons.length - 1 ) ];
756 }
757 focus_without_scrolling( fallback_target || editor.scope );
758 }, 0 );
759 }
760
761 /**
762 * Reproduce the native Booking Calendar range-hover highlight.
763 *
764 * @param {string} hovered_value Datepicker value supplied by the plugin.
765 * @param {Date} hovered_date Calendar day currently under the pointer.
766 * @return {boolean} True so the datepicker continues its normal handling.
767 */
768 function handle_calendar_hover( hovered_value, hovered_date ) {
769 var datepick_instance;
770 var range_start;
771 var cursor_date;
772 var safety_count = 0;
773
774 editor.calendar.find( '.datepick-days-cell-over' ).removeClass( 'datepick-days-cell-over' );
775 if ( ! hovered_date || ! $.datepick || 'function' !== typeof $.datepick._getInst ) {
776 return true;
777 }
778
779 datepick_instance = $.datepick._getInst( editor.calendar[ 0 ] );
780 if ( ! datepick_instance || ! datepick_instance.dates || 1 !== datepick_instance.dates.length || ! datepick_instance.dates[ 0 ] ) {
781 return true;
782 }
783
784 range_start = new Date( datepick_instance.dates[ 0 ].getTime() );
785 if ( hovered_date < range_start ) {
786 return true;
787 }
788
789 cursor_date = new Date( range_start.getTime() );
790 while ( cursor_date <= hovered_date && safety_count <= state.max_range_days ) {
791 editor.calendar.find( '.cal4date-' + String( cursor_date.getMonth() + 1 ) + '-' + String( cursor_date.getDate() ) + '-' + String( cursor_date.getFullYear() ) ).addClass( 'datepick-days-cell-over' );
792 cursor_date.setDate( cursor_date.getDate() + 1 );
793 safety_count += 1;
794 }
795
796 return true;
797 }
798
799 /**
800 * Initialize Booking Calendar's standard two-month range datepicker.
801 *
802 * @return {void}
803 */
804 function initialize_calendar() {
805 var first_date = parse_iso_date( selected_first_date );
806 var last_date = parse_iso_date( selected_last_date );
807
808 if ( ! $.fn.datepick ) {
809 editor.error.textContent = get_message( 'calendar_unavailable', 'The date selector could not be loaded. Reload the page and try again.' );
810 return;
811 }
812
813 editor.calendar.datepick( {
814 beforeShowDay: function ( date ) {
815 var day_presentation = get_calendar_day_presentation( date );
816
817 return [ true, day_presentation.css_classes, day_presentation.status_title ];
818 },
819 onHover: handle_calendar_hover,
820 onSelect: function ( date_string, selected_dates ) {
821 if ( ! Array.isArray( selected_dates ) || ! selected_dates[ 0 ] ) {
822 return;
823 }
824
825 selected_first_date = format_iso_date( selected_dates[ 0 ] );
826 selected_last_date = format_iso_date( selected_dates[ 1 ] || selected_dates[ 0 ] );
827 editor.error.textContent = '';
828 render_selection_summary();
829 },
830 showOn: 'none',
831 numberOfMonths: 2,
832 stepMonths: 1,
833 prevText: '&lsaquo;',
834 nextText: '&rsaquo;',
835 dateFormat: 'yy-mm-dd',
836 changeMonth: false,
837 changeYear: false,
838 minDate: parse_iso_date( state.today ) || 0,
839 maxDate: '10y',
840 showStatus: false,
841 closeAtTop: false,
842 firstDay: state.first_day,
843 gotoCurrent: false,
844 hideIfNoPrevNext: true,
845 multiSeparator: ', ',
846 multiSelect: 0,
847 rangeSelect: true,
848 rangeSeparator: ' ~ ',
849 useThemeRoller: false,
850 mandatory: true
851 } );
852
853 if ( first_date && last_date ) {
854 editor.calendar.datepick( 'setDate', first_date, last_date );
855 }
856 }
857
858 /**
859 * Handle Add, Clear, and Remove actions.
860 *
861 * @param {MouseEvent} event Delegated click event.
862 * @return {void}
863 */
864 function handle_click( event ) {
865 var remove_button = event.target.closest( '[data-wpbc-days-off-remove]' );
866 var remove_buttons;
867 var remove_index;
868 var day_count;
869
870 if ( event.target.closest( '[data-wpbc-days-off-add]' ) ) {
871 event.preventDefault();
872 day_count = get_selected_day_count();
873 if ( ! day_count || day_count > state.max_range_days ) {
874 editor.error.textContent = get_message( 'range_invalid', 'Select a valid unavailable date range.' );
875 return;
876 }
877 stage_range( 'add', String( editor.scope.value || 'all' ), selected_first_date, selected_last_date, editor.add_button, -1 );
878 return;
879 }
880
881 if ( event.target.closest( '[data-wpbc-days-off-clear]' ) ) {
882 event.preventDefault();
883 clear_selected_range();
884 return;
885 }
886
887 if ( remove_button ) {
888 event.preventDefault();
889 remove_buttons = Array.prototype.slice.call( editor.node.querySelectorAll( '[data-wpbc-days-off-remove]' ) );
890 remove_index = remove_buttons.indexOf( remove_button );
891 stage_range(
892 'remove',
893 String( remove_button.getAttribute( 'data-scope' ) || '' ),
894 String( remove_button.getAttribute( 'data-first-date' ) || '' ),
895 String( remove_button.getAttribute( 'data-last-date' ) || '' ),
896 null,
897 remove_index
898 );
899 }
900 }
901
902 /**
903 * Initialize the editor against domain-neutral shell services.
904 *
905 * @param {Object} registered_shell_api Shared shell services.
906 * @return {void}
907 */
908 function initialize( registered_shell_api ) {
909 var editor_node;
910
911 shell_api = registered_shell_api;
912 editor_node = shell_api.root.querySelector( '[data-wpbc-days-off-editor]' );
913 if ( ! editor_node ) {
914 return;
915 }
916
917 state = normalize_state( read_initial_state( editor_node ) );
918 if ( ! state.can_manage || ! state.has_resources ) {
919 return;
920 }
921
922 editor = {
923 node: editor_node,
924 calendar_column: editor_node.querySelector( '[data-wpbc-days-off-calendar-column]' ),
925 calendar: $( editor_node.querySelector( '[data-wpbc-days-off-calendar]' ) ),
926 first_date: editor_node.querySelector( '[data-wpbc-days-off-first-date]' ),
927 last_date: editor_node.querySelector( '[data-wpbc-days-off-last-date]' ),
928 scope: editor_node.querySelector( '[data-wpbc-days-off-scope]' ),
929 aggregate_legend: editor_node.querySelector( '[data-wpbc-days-off-aggregate-legend]' ),
930 resource_legend: editor_node.querySelector( '[data-wpbc-days-off-resource-legend]' ),
931 summary_range: editor_node.querySelector( '[data-wpbc-days-off-summary-range]' ),
932 summary_count: editor_node.querySelector( '[data-wpbc-days-off-summary-count]' ),
933 add_button: editor_node.querySelector( '[data-wpbc-days-off-add]' ),
934 error: editor_node.querySelector( '[data-wpbc-days-off-error]' ),
935 range_list: editor_node.querySelector( '[data-wpbc-days-off-ranges]' ),
936 empty: editor_node.querySelector( '[data-wpbc-days-off-empty]' ),
937 value: shell_api.root.querySelector( '[data-wpbc-days-off-value]' )
938 };
939 base_revision = state.revision;
940 all_unavailable_dates = normalize_unavailable_dates( state.unavailable_dates );
941
942 selected_first_date = state.default_first_date || state.today;
943 selected_last_date = state.default_last_date || selected_first_date;
944 apply_scope_value( state.scope );
945 editor.node.addEventListener( 'click', handle_click );
946 editor.scope.addEventListener( 'change', function () {
947 load_scope( String( editor.scope.value || 'all' ) );
948 } );
949 initialize_calendar();
950 render_selection_summary();
951 render_ranges();
952 sync_staged_value();
953 }
954
955 return {
956 initialize: initialize,
957 sync: sync_staged_value,
958 validate: function () {
959 return null;
960 }
961 };
962 }
963
964 window.wpbc_setup_editor_modules = window.wpbc_setup_editor_modules || {};
965 window.wpbc_setup_editor_modules.days_off = {
966 create: create_days_off_adapter
967 };
968
969 if ( window.wpbc_setup_wizard_api && 'function' === typeof window.wpbc_setup_wizard_api.register_step_adapter ) {
970 window.wpbc_setup_wizard_api.register_step_adapter( create_days_off_adapter( module_config ) );
971 }
972 }( jQuery, window, document ) );
973