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-booking-resources / _src / step-booking-resources.js

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

602 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Browser adapter for Setup Wizard Booking Resource creation and editing.
3 *
4 * The adapter keeps existing updates separate from new drafts. It sends only
5 * changed existing Resources, and the server remains authoritative for stale
6 * detection, authorization, edition rules, and persistence.
7 *
8 * @package Booking Calendar
9 */
10 ( function ( $, window, document ) {
11 'use strict';
12
13 var module_config = window.wpbc_setup_wizard_booking_resources || { i18n: {} };
14
15 /**
16 * Create one isolated Resource editor adapter.
17 *
18 * @param {Object} config Translated presentation strings.
19 * @return {Object} Setup Wizard step adapter.
20 */
21 function create_booking_resources_adapter( config ) {
22 var shell_api = null;
23 var root = null;
24 var editor = null;
25 var editable_fields = [ 'title', 'description', 'picture_url', 'base_cost' ];
26
27 /**
28 * Return a translated string or fallback.
29 *
30 * @param {string} key Translation key.
31 * @param {string} fallback Fallback text.
32 * @return {string} Localized text.
33 */
34 function get_message( key, fallback ) {
35 return config.i18n && config.i18n[ key ] ? config.i18n[ key ] : fallback;
36 }
37
38 /**
39 * Replace one `%s` token without interpreting replacement tokens.
40 *
41 * @param {string} message Message template.
42 * @param {string} replacement Plain-text replacement.
43 * @return {string} Formatted message.
44 */
45 function format_message( message, replacement ) {
46 return String( message ).replace( '%s', function () {
47 return String( replacement );
48 } );
49 }
50
51 /**
52 * Return a detached JSON-safe copy.
53 *
54 * @param {Object} source Source object.
55 * @return {Object} Detached copy.
56 */
57 function clone_resource( source ) {
58 return JSON.parse( JSON.stringify( source || {} ) );
59 }
60
61 /**
62 * Create one unsaved Resource proposal.
63 *
64 * @return {Object} JSON-safe draft.
65 */
66 function create_resource_draft() {
67 return {
68 draft_id: 'draft-' + Date.now() + '-' + Math.floor( Math.random() * 100000 ),
69 title: get_message( 'new_resource', 'New Booking Resource' ),
70 description: '',
71 picture_url: '',
72 base_cost: '0.00'
73 };
74 }
75
76 /**
77 * Return one details control by stable field name.
78 *
79 * @param {string} field_name Resource DTO field.
80 * @return {HTMLElement|null} Matching control.
81 */
82 function get_resource_field( field_name ) {
83 return editor ? editor.node.querySelector( '[data-wpbc-resource-field="' + field_name + '"]' ) : null;
84 }
85
86 /**
87 * Return the selected new or existing Resource.
88 *
89 * @return {Object|null} Selected Resource or null.
90 */
91 function get_selected_resource() {
92 if ( ! editor || null === editor.selected_index ) {
93 return null;
94 }
95 if ( 'existing' === editor.selected_type ) {
96 return editor.existing_resources[ editor.selected_index ] || null;
97 }
98
99 return editor.new_resources[ editor.selected_index ] || null;
100 }
101
102 /**
103 * Test whether editable values differ from authorized source values.
104 *
105 * @param {Object} resource Current browser Resource.
106 * @param {Object} baseline Authorized source Resource.
107 * @return {boolean} True when at least one editable field changed.
108 */
109 function resource_is_dirty( resource, baseline ) {
110 return editable_fields.some( function ( field_name ) {
111 return String( resource[ field_name ] || '' ) !== String( baseline[ field_name ] || '' );
112 } );
113 }
114
115 /**
116 * Build data-only update records for changed existing Resources.
117 *
118 * @return {Array} Existing Resource update payload.
119 */
120 function get_existing_updates() {
121 return editor.existing_resources.reduce( function ( updates, resource, resource_index ) {
122 var baseline = editor.existing_baselines[ resource_index ];
123 var update;
124
125 if ( ! baseline || ! resource_is_dirty( resource, baseline ) ) {
126 return updates;
127 }
128 update = {
129 resource_id: Number( resource.resource_id || resource.id || 0 ),
130 source_fingerprint: String( baseline.source_fingerprint || '' )
131 };
132 editable_fields.forEach( function ( field_name ) {
133 update[ field_name ] = String( resource[ field_name ] || '' );
134 } );
135 updates.push( update );
136
137 return updates;
138 }, [] );
139 }
140
141 /**
142 * Write new drafts and changed existing Resources to separate transports.
143 *
144 * @return {void}
145 */
146 function sync_transport() {
147 if ( ! editor ) {
148 return;
149 }
150 editor.new_transport.value = JSON.stringify( editor.new_resources );
151 editor.update_transport.value = JSON.stringify( get_existing_updates() );
152 }
153
154 /**
155 * Persist visible detail controls into the selected Resource.
156 *
157 * @return {void}
158 */
159 function sync_selected_resource() {
160 var resource = get_selected_resource();
161
162 if ( ! resource ) {
163 sync_transport();
164 return;
165 }
166 editable_fields.forEach( function ( field_name ) {
167 var field = get_resource_field( field_name );
168 if ( field && ! field.disabled ) {
169 resource[ field_name ] = field.value;
170 }
171 } );
172 sync_transport();
173 }
174
175 /**
176 * Update the selected Resource summary and picture preview.
177 *
178 * @return {void}
179 */
180 function render_details() {
181 var resource = get_selected_resource();
182 var fields = editor && editor.node.querySelector( '[data-wpbc-resource-fields]' );
183 var empty = editor && editor.node.querySelector( '[data-wpbc-resource-empty]' );
184 var title_control = get_resource_field( 'title' );
185 var picture_url;
186 var image;
187 var placeholder;
188 var remove_picture;
189
190 if ( fields ) {
191 fields.hidden = ! resource;
192 }
193 if ( empty ) {
194 empty.hidden = Boolean( resource );
195 }
196 if ( ! resource ) {
197 return;
198 }
199 if ( title_control ) {
200 title_control.setAttribute(
201 'data-wpbc-setup-wizard-error-control-for',
202 'existing' === editor.selected_type ? 'existing_booking_resources' : 'booking_resources'
203 );
204 }
205
206 editable_fields.forEach( function ( field_name ) {
207 var field = get_resource_field( field_name );
208 if ( field ) {
209 field.value = undefined !== resource[ field_name ] ? resource[ field_name ] : '';
210 }
211 } );
212
213 picture_url = String( resource.picture_url || '' ).trim();
214 image = editor.node.querySelector( '[data-wpbc-resource-picture-image]' );
215 placeholder = editor.node.querySelector( '[data-wpbc-resource-picture-placeholder]' );
216 remove_picture = editor.node.querySelector( '[data-wpbc-remove-resource-picture]' );
217 if ( image ) {
218 image.hidden = ! picture_url;
219 if ( picture_url ) {
220 image.src = picture_url;
221 } else {
222 image.removeAttribute( 'src' );
223 }
224 }
225 if ( placeholder ) {
226 placeholder.hidden = Boolean( picture_url );
227 }
228 if ( remove_picture ) {
229 remove_picture.disabled = ! editor.media_upload_available || ! picture_url;
230 }
231 }
232
233 /**
234 * Expose the existing Resource dirty state through its server-rendered badge.
235 *
236 * @param {HTMLElement} card Existing Resource card.
237 * @param {number} resource_index Resource index in the authorized list.
238 * @return {void}
239 */
240 function render_existing_card_status( card, resource_index ) {
241 var status_badge = card.querySelector( '[data-wpbc-resource-status]' );
242 var resource = editor.existing_resources[ resource_index ];
243 var baseline = editor.existing_baselines[ resource_index ];
244 var is_changed = Boolean( resource && baseline && resource_is_dirty( resource, baseline ) );
245
246 if ( status_badge ) {
247 status_badge.textContent = is_changed ? status_badge.dataset.changedLabel : status_badge.dataset.existingLabel;
248 status_badge.classList.toggle( 'is-existing', ! is_changed );
249 status_badge.classList.toggle( 'is-changed', is_changed );
250 }
251 }
252
253 /**
254 * Update the server-rendered existing Resource cards.
255 *
256 * @return {void}
257 */
258 function render_existing_cards() {
259 editor.existing_cards.forEach( function ( card, resource_index ) {
260 var resource = editor.existing_resources[ resource_index ];
261 var selection = card.querySelector( 'input[type="radio"]' );
262 var image = card.querySelector( 'img' );
263 var placeholder = card.querySelector( '.wpbc-bi-image-fill' );
264
265 if ( ! resource ) {
266 return;
267 }
268 card.classList.toggle( 'is-selected', 'existing' === editor.selected_type && resource_index === editor.selected_index );
269 selection.checked = 'existing' === editor.selected_type && resource_index === editor.selected_index;
270 card.querySelector( 'strong' ).textContent = resource.title || get_message( 'new_resource', 'New Booking Resource' );
271 render_existing_card_status( card, resource_index );
272 if ( resource.picture_url ) {
273 image.src = resource.picture_url;
274 image.hidden = false;
275 placeholder.hidden = true;
276 } else {
277 image.removeAttribute( 'src' );
278 image.hidden = true;
279 placeholder.hidden = false;
280 }
281 } );
282 }
283
284 /**
285 * Render new proposal cards and refresh existing card state.
286 *
287 * @return {void}
288 */
289 function render_cards() {
290 var fragment;
291
292 if ( ! editor || ! editor.new_list || ! editor.template ) {
293 return;
294 }
295 editor.new_list.innerHTML = '';
296 fragment = document.createDocumentFragment();
297 editor.new_resources.forEach( function ( resource, resource_index ) {
298 var card = editor.template.content.firstElementChild.cloneNode( true );
299 var selection = card.querySelector( 'input[type="radio"]' );
300 var image = card.querySelector( 'img' );
301 var placeholder = card.querySelector( '.wpbc-bi-image-fill' );
302 var remove_button = card.querySelector( '[data-wpbc-remove-resource]' );
303
304 card.classList.toggle( 'is-selected', 'new' === editor.selected_type && resource_index === editor.selected_index );
305 selection.checked = 'new' === editor.selected_type && resource_index === editor.selected_index;
306 selection.dataset.wpbcResourceSelection = 'new:' + String( resource_index );
307 card.querySelector( 'strong' ).textContent = resource.title || get_message( 'new_resource', 'New Booking Resource' );
308 if ( resource.picture_url ) {
309 image.src = resource.picture_url;
310 image.hidden = false;
311 placeholder.hidden = true;
312 }
313 remove_button.dataset.wpbcRemoveResource = String( resource_index );
314 remove_button.setAttribute( 'aria-label', format_message( get_message( 'remove_resource', 'Remove %s' ), resource.title || get_message( 'new_resource', 'New Booking Resource' ) ) );
315 fragment.appendChild( card );
316 } );
317 editor.new_list.appendChild( fragment );
318 render_existing_cards();
319 sync_transport();
320 render_details();
321 }
322
323 /**
324 * Select a Resource from a stable `type:index` browser value.
325 *
326 * @param {string} selection_value Browser selection value.
327 * @return {void}
328 */
329 function select_resource( selection_value ) {
330 var selection_parts = String( selection_value || '' ).split( ':' );
331 var selected_type = selection_parts[ 0 ];
332 var selected_index = parseInt( selection_parts[ 1 ], 10 );
333 var selected_card;
334 var selected_control;
335
336 if ( [ 'new', 'existing' ].indexOf( selected_type ) < 0 || isNaN( selected_index ) ) {
337 return;
338 }
339 selected_card = 'existing' === selected_type
340 ? editor.existing_cards[ selected_index ]
341 : editor.new_list.querySelector( '[data-wpbc-resource-selection="new:' + String( selected_index ) + '"]' );
342 if ( ! selected_card ) {
343 return;
344 }
345 sync_selected_resource();
346 editor.selected_type = selected_type;
347 editor.selected_index = selected_index;
348 render_cards();
349 selected_control = 'existing' === selected_type
350 ? selected_card.querySelector( 'input[type="radio"]' )
351 : editor.new_list.querySelector( '[data-wpbc-resource-selection="new:' + String( selected_index ) + '"]' );
352 if ( selected_control ) {
353 try {
354 selected_control.focus( { preventScroll: true } );
355 } catch ( focus_error ) {
356 selected_control.focus();
357 }
358 }
359 }
360
361 /**
362 * Handle delegated editor button actions.
363 *
364 * @param {MouseEvent} event Click event.
365 * @return {void}
366 */
367 function handle_click( event ) {
368 var remove_button = event.target.closest( '[data-wpbc-remove-resource]' );
369 var remove_index;
370 var focus_target;
371
372 if ( event.target.closest( '[data-wpbc-add-resource]' ) ) {
373 if ( ! editor.can_create || editor.new_resources.length >= editor.max_resources ) {
374 shell_api.set_field_error( 'booking_resources', get_message( 'resource_limit', 'The Booking Resource limit for this account has been reached.' ) );
375 return;
376 }
377 sync_selected_resource();
378 editor.new_resources.push( create_resource_draft() );
379 editor.selected_type = 'new';
380 editor.selected_index = editor.new_resources.length - 1;
381 render_cards();
382 shell_api.clear_field_error( 'booking_resources' );
383 get_resource_field( 'title' ).focus();
384 return;
385 }
386
387 if ( remove_button ) {
388 remove_index = parseInt( remove_button.dataset.wpbcRemoveResource, 10 );
389 if ( ! isNaN( remove_index ) && editor.new_resources[ remove_index ] ) {
390 editor.new_resources.splice( remove_index, 1 );
391 if ( editor.new_resources.length ) {
392 editor.selected_type = 'new';
393 editor.selected_index = Math.max( 0, Math.min( editor.selected_index, editor.new_resources.length - 1 ) );
394 } else if ( editor.existing_resources.length ) {
395 editor.selected_type = 'existing';
396 editor.selected_index = 0;
397 } else {
398 editor.selected_type = null;
399 editor.selected_index = null;
400 }
401 render_cards();
402 shell_api.set_status( get_message( 'resource_removed', 'Unsaved Booking Resource removed from this setup.' ) );
403 focus_target = editor.new_list.querySelector( 'input[type="radio"]' ) || ( editor.existing_list ? editor.existing_list.querySelector( 'input[type="radio"]' ) : null ) || editor.node.querySelector( '[data-wpbc-add-resource]' );
404 if ( focus_target ) {
405 focus_target.focus();
406 }
407 }
408 return;
409 }
410
411 if ( event.target.closest( '[data-wpbc-remove-resource-picture]' ) && editor.media_upload_available ) {
412 var picture_field = get_resource_field( 'picture_url' );
413 if ( picture_field ) {
414 picture_field.value = '';
415 sync_selected_resource();
416 render_cards();
417 }
418 }
419 }
420
421 /**
422 * Handle selection and committed field changes.
423 *
424 * @param {Event} event Change event.
425 * @return {void}
426 */
427 function handle_change( event ) {
428 if ( event.target.matches( '[data-wpbc-resource-selection]' ) ) {
429 select_resource( event.target.dataset.wpbcResourceSelection );
430 return;
431 }
432 if ( event.target.matches( '[data-wpbc-resource-field]' ) ) {
433 sync_selected_resource();
434 render_cards();
435 shell_api.clear_field_error( 'booking_resources' );
436 shell_api.clear_field_error( 'existing_booking_resources' );
437 }
438 }
439
440 /**
441 * Handle live text editing without reconstructing the active controls.
442 *
443 * @param {InputEvent} event Input event.
444 * @return {void}
445 */
446 function handle_input( event ) {
447 var resource;
448 var card;
449
450 if ( ! event.target.matches( '[data-wpbc-resource-field]' ) ) {
451 return;
452 }
453 sync_selected_resource();
454 resource = get_selected_resource();
455 card = 'existing' === editor.selected_type ? editor.existing_cards[ editor.selected_index ] : editor.new_list.children[ editor.selected_index ];
456 if ( card && resource ) {
457 card.querySelector( 'strong' ).textContent = resource.title || get_message( 'new_resource', 'New Booking Resource' );
458 if ( 'existing' === editor.selected_type ) {
459 render_existing_card_status( card, editor.selected_index );
460 }
461 }
462 shell_api.clear_field_error( 'booking_resources' );
463 shell_api.clear_field_error( 'existing_booking_resources' );
464 }
465
466 /**
467 * Refresh after the shared WordPress media picker sets the URL field.
468 *
469 * @return {void}
470 */
471 function handle_media_url_set() {
472 sync_selected_resource();
473 render_cards();
474 }
475
476 /**
477 * Parse one JSON array transport without trusting its shape.
478 *
479 * @param {string} encoded_value JSON value.
480 * @return {Array} Decoded array or an empty array.
481 */
482 function parse_array( encoded_value ) {
483 var parsed_value;
484
485 try {
486 parsed_value = JSON.parse( encoded_value || '[]' );
487 } catch ( parse_error ) {
488 parsed_value = [];
489 }
490
491 return Array.isArray( parsed_value ) ? parsed_value : [];
492 }
493
494 /**
495 * Initialize this step from the shell and data-only transports.
496 *
497 * @param {Object} registered_shell_api Shared wizard services.
498 * @return {void}
499 */
500 function initialize( registered_shell_api ) {
501 var editor_node;
502 var new_transport;
503 var update_transport;
504 var existing_source;
505 var existing_resources;
506
507 shell_api = registered_shell_api;
508 root = shell_api.root;
509 editor_node = root.querySelector( '[data-wpbc-booking-resources-editor]' );
510 new_transport = root.querySelector( '[data-wpbc-booking-resource-drafts]' );
511 update_transport = root.querySelector( '[data-wpbc-existing-resource-updates]' );
512 existing_source = root.querySelector( '[data-wpbc-existing-resource-source]' );
513 if ( ! editor_node || ! new_transport || ! update_transport || ! existing_source ) {
514 return;
515 }
516
517 existing_resources = parse_array( existing_source.value );
518 editor = {
519 node: editor_node,
520 new_transport: new_transport,
521 update_transport: update_transport,
522 new_list: editor_node.querySelector( '[data-wpbc-new-resource-list]' ),
523 existing_list: editor_node.querySelector( '[data-wpbc-existing-resource-list]' ),
524 existing_cards: Array.prototype.slice.call( editor_node.querySelectorAll( '[data-wpbc-existing-resource-card]' ) ),
525 template: root.querySelector( '[data-wpbc-resource-card-template]' ),
526 new_resources: parse_array( new_transport.value ),
527 existing_resources: existing_resources.map( clone_resource ),
528 existing_baselines: existing_resources.map( clone_resource ),
529 selected_type: null,
530 selected_index: null,
531 can_create: 'true' === editor_node.dataset.canCreate,
532 pricing_available: 'true' === editor_node.dataset.pricingAvailable,
533 media_upload_available: 'true' === editor_node.dataset.mediaUploadAvailable,
534 max_resources: parseInt( new_transport.dataset.maxResourceDrafts || '0', 10 )
535 };
536 if ( editor.new_resources.length ) {
537 editor.selected_type = 'new';
538 editor.selected_index = 0;
539 } else if ( editor.existing_resources.length ) {
540 editor.selected_type = 'existing';
541 editor.selected_index = 0;
542 }
543 editor.node.addEventListener( 'click', handle_click );
544 editor.node.addEventListener( 'change', handle_change );
545 editor.node.addEventListener( 'input', handle_input );
546 $( document )
547 .off( 'wpbc_media_upload_url_set.wpbcSetupWizardResources', '#wpbc-setup-wizard-resource-picture-url' )
548 .on( 'wpbc_media_upload_url_set.wpbcSetupWizardResources', '#wpbc-setup-wizard-resource-picture-url', handle_media_url_set );
549 render_cards();
550 }
551
552 /**
553 * Validate new drafts and changed existing Resources before navigation.
554 *
555 * @return {HTMLElement|null} First invalid control or null.
556 */
557 function validate() {
558 var first_invalid = null;
559
560 sync_selected_resource();
561 [
562 { type: 'new', resources: editor.new_resources, field_id: 'booking_resources' },
563 { type: 'existing', resources: editor.existing_resources, field_id: 'existing_booking_resources' }
564 ].some( function ( collection ) {
565 return collection.resources.some( function ( resource, resource_index ) {
566 var baseline = 'existing' === collection.type ? editor.existing_baselines[ resource_index ] : null;
567 var price = Number( resource.base_cost );
568 var title_missing = ! String( resource.title || '' ).trim();
569
570 if ( baseline && ! resource_is_dirty( resource, baseline ) ) {
571 return false;
572 }
573 if ( ! title_missing && ( ! editor.pricing_available || ( isFinite( price ) && price >= 0 ) ) ) {
574 return false;
575 }
576 editor.selected_type = collection.type;
577 editor.selected_index = resource_index;
578 render_cards();
579 first_invalid = get_resource_field( title_missing ? 'title' : 'base_cost' );
580 shell_api.set_field_error( collection.field_id, title_missing ? get_message( 'resource_title_required', 'Enter a name for every Booking Resource.' ) : get_message( 'resource_price_invalid', 'Enter a valid non-negative base cost.' ) );
581
582 return true;
583 } );
584 } );
585
586 return first_invalid;
587 }
588
589 return {
590 initialize: initialize,
591 sync: sync_selected_resource,
592 validate: validate
593 };
594 }
595
596 window.wpbc_setup_editor_modules = window.wpbc_setup_editor_modules || {};
597 window.wpbc_setup_editor_modules.booking_resources = { create: create_booking_resources_adapter };
598 if ( window.wpbc_setup_wizard_api && 'function' === typeof window.wpbc_setup_wizard_api.register_step_adapter ) {
599 window.wpbc_setup_wizard_api.register_step_adapter( create_booking_resources_adapter( module_config ) );
600 }
601 }( jQuery, window, document ) );
602