| 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: '‹', |
| 834 |
nextText: '›', |
| 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 |
|