1(function () { 2 'use strict'; 3 4 let namespace = '/motopress/appointment/v1'; 5 6 /** 7 * @param {String} route 8 * @param {Object} args Optional. 9 * @param {String} type Optional. 'GET' by default. 10 * @return {Promise} 11 * 12 * @since 1.0 13 */ 14 function mpa_rest_request(route, args = {}, type = 'GET') { 15 return new Promise((resolve, reject) => { 16 wp.apiRequest({ 17 path: namespace + route, 18 type: type, 19 data: args 20 }) 21 // Convert jqXHR object into Promise 22 .done(responseData => resolve(responseData)).fail((request, statusText) => { 23 let message = 'parsererror'; // Default response for PHP error 24 25 // Get error message 26 if (request.responseJSON && request.responseJSON.message) { 27 message = request.responseJSON.message; 28 } else { 29 message = `Status: ${statusText}`; 30 } 31 if (message == 'parsererror') { 32 message = 'REST request failed. Maybe PHP error on the server side. Check PHP logs.'; 33 } 34 reject(new Error(message)); 35 }); 36 }); 37 } 38 39 /** 40 * @param {String} route 41 * @param {Object} args Optional. 42 * @return {Promise} 43 * 44 * @since 1.0 45 */ 46 function mpa_rest_get(route, args = {}) { 47 return mpa_rest_request(route, args, 'GET'); 48 } 49 50 /** 51 * @param {String} route 52 * @param {Object} args 53 * @return {Promise} 54 * 55 * @since 1.0 56 */ 57 function mpa_rest_post(route, args) { 58 return mpa_rest_request(route, args, 'POST'); 59 } 60 61 /** 62 * @since 1.0 63 */ 64 class Settings { 65 /** 66 * @since 1.0 67 */ 68 constructor() { 69 this.settings = this.getDefaults(); 70 this.loadingPromise = this.load(); 71 } 72 73 /** 74 * @since 1.0 75 * 76 * @access protected 77 * 78 * @return {Object} 79 */ 80 getDefaults() { 81 return { 82 plugin_name: 'Appointment Booking', 83 // No translation required 84 today: '2030-01-01', 85 business_name: '', 86 default_time_step: 30, 87 default_booking_status: 'confirmed', 88 confirmation_mode: 'auto', 89 terms_page_id_for_acceptance: 0, 90 allow_multibooking: false, 91 allow_coupons: false, 92 allow_customer_account_creation: false, 93 country: '', 94 currency: 'EUR', 95 currency_symbol: '€', 96 currency_position: 'before', 97 decimal_separator: '.', 98 thousand_separator: ',', 99 number_of_decimals: 2, 100 date_format: 'F j, Y', 101 time_format: 'H:i', 102 week_starts_on: 0, 103 thumbnail_size: { 104 width: 150, 105 height: 150 106 }, 107 flatpickr_locale: 'en', 108 enable_payments: false, 109 active_gateways: [], 110 payment_received_page_url: '', 111 failed_transaction_page_url: '', 112 default_payment_gateway: '' 113 }; 114 } 115 116 /** 117 * @return {Promise} 118 * 119 * @access protected 120 * 121 * @since 1.0 122 */ 123 load() { 124 return new Promise((resolve, reject) => { 125 mpa_rest_get('/settings?nocache').then(responseData => this.settings = responseData, error => console.error('Unable to load public settings.', error)).finally(() => resolve(this.settings)); 126 }); 127 } 128 129 /** 130 * @return {Promise} 131 * 132 * @since 1.0 133 */ 134 ready() { 135 return this.loadingPromise; 136 } 137 138 /** 139 * @since 1.5.0 140 * 141 * @return {String} 142 */ 143 getPluginName() { 144 return this.settings.plugin_name; 145 } 146 147 /** 148 * @since 1.11.0 149 * 150 * @return {String} 'Y-m-d' 151 */ 152 getBusinessDate() { 153 return this.settings.today; 154 } 155 156 /** 157 * @return {String} 158 */ 159 getBusinessName() { 160 return this.settings.business_name; 161 } 162 163 /** 164 * @return {Number} Step for time slots (in minutes). 165 * 166 * @since 1.0 167 */ 168 getTimeStep() { 169 return this.settings.default_time_step; 170 } 171 172 /** 173 * @return {String} 'pending' or 'confirmed'. 174 * 175 * @since 1.1.0 176 */ 177 getDefaultBookingStatus() { 178 return this.settings.default_booking_status; 179 } 180 181 /** 182 * @since 1.5.0 183 * @return {String} auto|manual|payment 184 */ 185 getConfirmationMode() { 186 return this.settings.confirmation_mode; 187 } 188 189 /** 190 * @since 1.10.2 191 * @return {Integer} 0 | page id 192 */ 193 getTermsPageIdForAcceptance() { 194 return this.settings.terms_page_id_for_acceptance; 195 } 196 197 /** 198 * @since 1.4.0 199 * 200 * @return {Boolean} 201 */ 202 isMultibookingEnabled() { 203 return this.settings.allow_multibooking; 204 } 205 206 /** 207 * @since 1.11.0 208 * 209 * @return {Boolean} 210 */ 211 isCouponsEnabled() { 212 return this.settings.allow_coupons; 213 } 214 215 /** 216 * @since 1.18.0 217 * 218 * @return {Boolean} 219 */ 220 isAllowCustomerAccountCreation() { 221 return this.settings.allow_customer_account_creation; 222 } 223 224 /** 225 * @since 1.5.0 226 * 227 * @return {String} 228 */ 229 getCountry() { 230 return this.settings.country; 231 } 232 233 /** 234 * @return {String} Currency code, like "EUR". 235 * 236 * @since 1.0 237 */ 238 getCurrency() { 239 return this.settings.currency; 240 } 241 242 /** 243 * @return {String} 244 * 245 * @since 1.0 246 */ 247 getCurrencySymbol() { 248 return this.settings.currency_symbol; 249 } 250 251 /** 252 * @return {String} 253 * 254 * @since 1.0 255 */ 256 getCurrencyPosition() { 257 return this.settings.currency_position; 258 } 259 260 /** 261 * @return {String} 262 * 263 * @since 1.0 264 */ 265 getDecimalSeparator() { 266 return this.settings.decimal_separator; 267 } 268 269 /** 270 * @return {String} 271 * 272 * @since 1.0 273 */ 274 getThousandSeparator() { 275 return this.settings.thousand_separator; 276 } 277 278 /** 279 * @return {Number} 280 * 281 * @since 1.0 282 */ 283 getDecimalsCount() { 284 return this.settings.number_of_decimals; 285 } 286 287 /** 288 * @return {String} 289 * 290 * @since 1.0 291 */ 292 getDateFormat() { 293 return this.settings.date_format; 294 } 295 296 /** 297 * @return {String} 298 * 299 * @since 1.0 300 */ 301 getTimeFormat() { 302 return this.settings.time_format; 303 } 304 305 /** 306 * @return {Number} 307 * 308 * @since 1.0 309 */ 310 getFirstDayOfWeek() { 311 return this.settings.week_starts_on; 312 } 313 314 /** 315 * @since 1.4.0 316 * 317 * @return {Object} {width, height} 318 */ 319 getThumbnailSize() { 320 return this.settings.thumbnail_size; 321 } 322 323 /** 324 * @return {String} 325 * 326 * @since 1.2.1 327 */ 328 getFlatpickrLocale() { 329 return this.settings.flatpickr_locale; 330 } 331 332 /** 333 * @since 1.5.0 334 * 335 * @return {Boolean} 336 */ 337 isPaymentsEnabled() { 338 return this.settings.enable_payments; 339 } 340 341 /** 342 * @since 1.5.0 343 * 344 * @return {String[]} 345 */ 346 getActiveGateways() { 347 return this.settings.active_gateways; 348 } 349 350 /** 351 * @since 1.5.0 352 * 353 * @return {String} 354 */ 355 getPaymentReceivedPageUrl() { 356 return this.settings.payment_received_page_url; 357 } 358 359 /** 360 * @since 1.5.0 361 * 362 * @return {String} 363 */ 364 getFailedTransactionPageUrl() { 365 return this.settings.failed_transaction_page_url; 366 } 367 368 /** 369 * @since 1.6.2 370 * 371 * @return {String} 372 */ 373 getDefaultPaymentGateway() { 374 return this.settings.default_payment_gateway; 375 } 376 } 377
378 /** 379 * @since 1.0 380 */ 381 class Plugin { 382 /** 383 * @since 1.0 384 */ 385 constructor() { 386 this.settingsCtrl = new Settings(); 387 this.loadingPromise = this.load(); 388 } 389 390 /** 391 * @return {Promise} 392 * 393 * @since 1.0 394 */ 395 load() { 396 return Promise.all([this.settingsCtrl.ready()]).then(() => this); 397 } 398 399 /** 400 * @return {Promise} 401 * 402 * @since 1.0 403 */ 404 ready() { 405 return this.loadingPromise; 406 } 407 408 /** 409 * @since 1.0 410 */ 411 settings() { 412 return this.settingsCtrl; 413 } 414 415 /** 416 * @since 1.0 417 */ 418 static getInstance() { 419 if (Plugin.instance == undefined) { 420 Plugin.instance = new Plugin(); 421 } 422 return Plugin.instance; 423 } 424 } 425 426 /** 427 * @return {Plugin} 428 * 429 * @since 1.0 430 */ 431 function mpapp() { 432 return Plugin.getInstance(); 433 } 434 435 const localTranslate = (text, domain = '') => text; 436 const localTranslateWithContext = (text, context, domain = '') => text; 437 const localSprintf = (format, ...args) => { 438 let argIndex = 0; 439 return format.replace(/%([sdf])/g, (match, specifier) => { 440 if (argIndex >= args.length) return match; 441 let value = args[argIndex++]; 442 switch (specifier) { 443 case 's': 444 return String(value); 445 case 'd': 446 return parseInt(value, 10); 447 case 'f': 448 return parseFloat(value); 449 default: 450 return match; 451 } 452 }); 453 }; 454 const __ = typeof wp !== 'undefined' && wp.i18n && wp.i18n.__ ? wp.i18n.__ : localTranslate; 455 const _x = typeof wp !== 'undefined' && wp.i18n && wp.i18n._x ? wp.i18n._x : localTranslateWithContext; 456 typeof wp !== 'undefined' && wp.i18n && wp.i18n.sprintf ? wp.i18n.sprintf : localSprintf; 457 458 const locale = { 459 weekdays: { 460 shorthand: [__('Sun', 'motopress-appointment'), __('Mon', 'motopress-appointment'), __('Tue', 'motopress-appointment'), __('Wed', 'motopress-appointment'), __('Thu', 'motopress-appointment'), __('Fri', 'motopress-appointment'), __('Sat', 'motopress-appointment')], 461 longhand: [__('Sunday', 'motopress-appointment'), __('Monday', 'motopress-appointment'), __('Tuesday', 'motopress-appointment'), __('Wednesday', 'motopress-appointment'), __('Thursday', 'motopress-appointment'), __('Friday', 'motopress-appointment'), __('Saturday', 'motopress-appointment')] 462 }, 463 months: { 464 shorthand: [__('Jan', 'motopress-appointment'), __('Feb', 'motopress-appointment'), __('Mar', 'motopress-appointment'), __('Apr', 'motopress-appointment'), 465 // Translators: Month name (short variant, like "Apr"). 466 _x('May', 'Month (short)', 'motopress-appointment'), __('Jun', 'motopress-appointment'), __('Jul', 'motopress-appointment'), __('Aug', 'motopress-appointment'), __('Sep', 'motopress-appointment'), __('Oct', 'motopress-appointment'), __('Nov', 'motopress-appointment'), __('Dec', 'motopress-appointment')], 467 longhand: [__('January', 'motopress-appointment'), __('February', 'motopress-appointment'), __('March', 'motopress-appointment'), __('April', 'motopress-appointment'), _x('May', 'Month', 'motopress-appointment'), __('June', 'motopress-appointment'), __('July', 'motopress-appointment'), __('August', 'motopress-appointment'), __('September', 'motopress-appointment'), __('October', 'motopress-appointment'), __('November', 'motopress-appointment'), __('December', 'motopress-appointment')] 468 }, 469 amPM: ['AM', 'PM'], 470 firstDayOfWeek: mpapp().settings().getFirstDayOfWeek() 471 }; 472 473 /** 474 * @return {String} 475 * 476 * @since 1.0 477 */ 478 function mpa_date_format() { 479 return mpapp().settings().getDateFormat(); 480 } 481 482 /** 483 * Based on Flatpickr's formatDate(). 484 * 485 * @see Flatpickr.formatDate(): https://github.com/flatpickr/flatpickr/blob/master/src/utils/formatting.ts 486 * @see PHP date formatting tokens: https://www.php.net/manual/en/datetime.format.php 487 * 488 * @param {Date|String} date 489 * @param {String} Optional. 'public', 'internal' ('Y-m-d') or custom date 490 * format. 'public' by default. 491 * @return {String} 492 * 493 * @since 1.0 494 */ 495 function mpa_format_date(date, format = 'public') { 496 if (typeof date == 'string') { 497 return date; // Already stringified 498 } 499 500 if (format == 'internal') { 501 return mpa_format_date(date, 'Y-m-d'); 502 } else if (format == 'public') { 503 return mpa_format_date(date, mpa_date_format()); 504 } 505 506 // Handle custom date format 507 let pad = (number, length = 2) => {
508 // Use at least two zeros for a padding to support milliseconds 509 return ('00' + number).slice(-length); 510 }; 511 512 // Go through the each symbol 513 let isSlashed = false; 514 let dateString = format.split('').map(char => { 515 // Handle slashes '\' 516 if (isSlashed) { 517 isSlashed = false; 518 return char; 519 } 520 521 // Handle any other symbols 522 switch (char) { 523 // Handle slashes '\' 524 case '\\': 525 isSlashed = true; 526 return ''; 527 // Day 528 case 'j': 529 // 1 to 31 530 return date.getDate(); 531 case 'd': 532 // 01 to 31 533 return pad(date.getDate()); 534 case 'D': 535 // Mon through Sun 536 return locale.weekdays.shorthand[date.getDay()]; 537 case 'l': 538 // Monday through Sunday 539 return locale.weekdays.longhand[date.getDay()]; 540 case 'N': 541 // 1 (Monday) to 7 (Sunday) 542 return date.getDay() || 7; 543 case 'w': 544 // 0 (Sunday) to 6 (Saturday) 545 return date.getDay(); 546 case 'z': 547 // 0 to 365 548 // Thanks to Alex Turpin https://stackoverflow.com/a/8619946 549 let yearStart = new Date(date.getFullYear(), 0, 1); 550 551 // Compensate daylight savings time 552 let timezoneOffset = yearStart.getTimezoneOffset() - date.getTimezoneOffset(); 553 let diffMilliseconds = date - yearStart + timezoneOffset * 60 * 1000; 554 let millisecondsInDay = 1000 * 60 * 60 * 24; 555 return Math.floor(diffMilliseconds / millisecondsInDay); 556 557 // Week 558 case 'W': 559 // 1 to 52 560 // Thanks to RobG https://stackoverflow.com/a/6117889 561 // Don't modify the original date 562 let cloneDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); 563 // Use 7 as a day number for Sunday 564 let currentDay = cloneDate.getUTCDay() || 7; 565 // Set to nearest Thursday: %current date% + 4 - %current day% 566 cloneDate.setUTCDate(cloneDate.getUTCDate() + 4 - currentDay); 567 // Get the first day of the year 568 let utcYear = new Date(Date.UTC(cloneDate.getUTCFullYear(), 0, 1)); 569 // Calculate full weeks to nearest Thursday 570 let oneDayMs = 1000 * 60 * 60 * 24; 571 let weekNo = Math.ceil(((cloneDate - utcYear) / oneDayMs + 1) / 7); 572 return weekNo; 573 574 // Month 575 case 'F': 576 // January through December 577 return locale.months.longhand[date.getMonth()]; 578 case 'M': 579 // Jan through Dec 580 return locale.months.shorthand[date.getMonth()]; 581 case 'm': 582 // 01 to 12 583 return pad(date.getMonth() + 1); 584 case 'n': 585 // 1 to 12 586 return date.getMonth() + 1; 587 case 't': 588 // 28 to 31 589 // Here is a trick. The days are 1-based. So when we pass 0 as a day number 590 // then JS Date object goes a day before (the last day of the previous month). 591 // So that's how it works: we select the 0-day of the next month and then go 592 // to the last day of the previous (required) month 593 let lastDate = new Date(date.getFullYear(), date.getMonth() + 1, 0); 594 return lastDate.getDate(); 595 596 // Year 597 case 'Y': 598 // Examples: 1999 or 2003 599 return date.getFullYear(); 600 case 'y': 601 // Examples: 99 or 03 602 return String(date.getFullYear()).substring(2); 603 case 'L': 604 // 1 (it's a leap year) or 0 (otherwise) 605 return date.getFullYear() % 4 == 0 ? 1 : 0; 606 607 // Time 608 case 'A': 609 // AM or PM 610 return locale.amPM[date.getHours() > 11 ? 1 : 0]; 611 case 'a': 612 // am or pm 613 return locale.amPM[date.getHours() > 11 ? 1 : 0].toLowerCase(); 614 case 'H': 615 // Hours, 00 to 23 616 return pad(date.getHours()); 617 case 'h': 618 // Hours, 01 to 12 619 return pad(date.getHours() % 12 || 12); 620 case 'G': 621 // Hours, 0 to 23 622 return date.getHours(); 623 case 'g': 624 // Hours, 1 to 12 625 return date.getHours() % 12 || 12; 626 case 'i': 627 // Minutes, 00 to 59 628 return pad(date.getMinutes()); 629 case 's': 630 // Seconds, 00 to 59 631 return pad(date.getSeconds()); 632 case 'v': 633 // Milliseconds, 000 to 999 634 return pad(date.getMilliseconds(), 3); 635 case 'u': 636 // Microseconds, 000000 to 999999 637 return pad(date.getMilliseconds(), 3) + '000'; 638 // Partial support 639 640 // Timezone 641 case 'O': // +0200 642 case 'P': 643 // +02:00 644 let offset = -date.getTimezoneOffset(); 645 let sign = offset >= 0 ? '+' : '-'; 646 let hours = Math.floor(Math.abs(offset) / 60); 647 let minutes = Math.abs(offset) % 60; 648 let separator = char == 'O' ? '' : ':'; 649 return sign + pad(hours) + separator + pad(minutes); 650 case 'Z': 651 // Offset in seconds, -43200 to 50400 652 return date.getTimezoneOffset() * 60; 653 654 // Full date/time 655 case 'U': 656 // Seconds since the Unix Epoch (January 1 1970 00:00:00 GMT) 657 return Math.floor(date.getTime() / 1000); 658 case 'c':
vendor: 13,539 bytes, lines 659-1154
659 // ISO 8601 date, like: 2004-02-12T15:19:21+00:00 660 661 // date.toISOString() will return the string with the suffix 662 // '.065Z' (milliseconds) instead of '+02:00' 663 return mpa_format_date(date, 'Y-m-d\\TH:i:sP'); 664 case 'r': 665 // RFC 2822 date, like: Thu, 21 Dec 2000 16:01:07 +0200 666 667 // date.toUTCString() will return the string with the timezone 668 // identifier suffix (GMT) instead of '+0200' 669 return mpa_format_date(date, 'D, d M Y H:i:s O'); 670 671 // Not supported 672 case 'S': // Ordinal suffix, like: st, nd, rd or th 673 case 'o': // ISO-8601 week-numbering year (examples: 1999 or 2003) 674 case 'B': // Swatch Internet time 675 case 'e': // Timezone identifier, like: UTC, GMT or Atlantic/Azores 676 case 'T': // Timezone abbreviation, like: EST, MDT, etc. 677 case 'I': 678 // Whether or not the date is in daylight saving time 679 return ''; 680 681 // Undefined 682 default: 683 return char; 684 } // switch (char) 685 }) // Map each symbol 686 .join(''); 687 return dateString; 688 } 689 690 /** 691 * @param {String} dateString Only the internal format is acceptable - 'Y-m-d'. 692 * @return {Date|null} 693 * 694 * @since 1.0 695 * @since 1.11.0 returns null if the date string is invalid. 696 */ 697 function mpa_parse_date(dateString) { 698 let date = dateString.match(mpa_validate_date_pattern()); 699 if (date != null) { 700 let year = parseInt(date[1]); 701 let month = parseInt(date[2]); 702 let day = parseInt(date[3]); 703 return new Date(year, month - 1, day); 704 } else { 705 return null; 706 } 707 } 708 709 /** 710 * @return {RegExp} 711 * 712 * @since 1.0 713 */ 714 function mpa_validate_date_pattern() { 715 return /(\d{4})-(\d{2})-(\d{2})/; 716 } 717 718 /** 719 * @return {Date} 720 * 721 * @since 1.0 722 */ 723 function mpa_today() { 724 let today = new Date(); 725 today.setHours(0, 0, 0, 0); 726 return today; 727 } 728 729 function getDefaultExportFromCjs (x) { 730 return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; 731 } 732 733 var md5$1 = {exports: {}}; 734 735 var crypt = {exports: {}}; 736 737 (function() { 738 var base64map 739 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', 740 741 crypt$1 = { 742 // Bit-wise rotation left 743 rotl: function(n, b) { 744 return (n << b) | (n >>> (32 - b)); 745 }, 746 747 // Bit-wise rotation right 748 rotr: function(n, b) { 749 return (n << (32 - b)) | (n >>> b); 750 }, 751 752 // Swap big-endian to little-endian and vice versa 753 endian: function(n) { 754 // If number given, swap endian 755 if (n.constructor == Number) { 756 return crypt$1.rotl(n, 8) & 0x00FF00FF | crypt$1.rotl(n, 24) & 0xFF00FF00; 757 } 758 759 // Else, assume array and swap all items 760 for (var i = 0; i < n.length; i++) 761 n[i] = crypt$1.endian(n[i]); 762 return n; 763 }, 764 765 // Generate an array of any length of random bytes 766 randomBytes: function(n) { 767 for (var bytes = []; n > 0; n--) 768 bytes.push(Math.floor(Math.random() * 256)); 769 return bytes; 770 }, 771 772 // Convert a byte array to big-endian 32-bit words 773 bytesToWords: function(bytes) { 774 for (var words = [], i = 0, b = 0; i < bytes.length; i++, b += 8) 775 words[b >>> 5] |= bytes[i] << (24 - b % 32); 776 return words; 777 }, 778 779 // Convert big-endian 32-bit words to a byte array 780 wordsToBytes: function(words) { 781 for (var bytes = [], b = 0; b < words.length * 32; b += 8) 782 bytes.push((words[b >>> 5] >>> (24 - b % 32)) & 0xFF); 783 return bytes; 784 }, 785 786 // Convert a byte array to a hex string 787 bytesToHex: function(bytes) { 788 for (var hex = [], i = 0; i < bytes.length; i++) { 789 hex.push((bytes[i] >>> 4).toString(16)); 790 hex.push((bytes[i] & 0xF).toString(16)); 791 } 792 return hex.join(''); 793 }, 794 795 // Convert a hex string to a byte array 796 hexToBytes: function(hex) { 797 for (var bytes = [], c = 0; c < hex.length; c += 2) 798 bytes.push(parseInt(hex.substr(c, 2), 16)); 799 return bytes; 800 }, 801 802 // Convert a byte array to a base-64 string 803 bytesToBase64: function(bytes) { 804 for (var base64 = [], i = 0; i < bytes.length; i += 3) { 805 var triplet = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]; 806 for (var j = 0; j < 4; j++) 807 if (i * 8 + j * 6 <= bytes.length * 8) 808 base64.push(base64map.charAt((triplet >>> 6 * (3 - j)) & 0x3F)); 809 else 810 base64.push('='); 811 } 812 return base64.join(''); 813 }, 814 815 // Convert a base-64 string to a byte array 816 base64ToBytes: function(base64) { 817 // Remove non-base-64 characters 818 base64 = base64.replace(/[^A-Z0-9+\/]/ig, ''); 819 820 for (var bytes = [], i = 0, imod4 = 0; i < base64.length; 821 imod4 = ++i % 4) { 822 if (imod4 == 0) continue; 823 bytes.push(((base64map.indexOf(base64.charAt(i - 1)) 824 & (Math.pow(2, -2 * imod4 + 8) - 1)) << (imod4 * 2)) 825 | (base64map.indexOf(base64.charAt(i)) >>> (6 - imod4 * 2))); 826 } 827 return bytes; 828 } 829 }; 830 831 crypt.exports = crypt$1; 832 })(); 833 834 var cryptExports = crypt.exports; 835 836 var charenc = { 837 // UTF-8 encoding 838 utf8: { 839 // Convert a string to a byte array 840 stringToBytes: function(str) { 841 return charenc.bin.stringToBytes(unescape(encodeURIComponent(str))); 842 }, 843 844 // Convert a byte array to a string 845 bytesToString: function(bytes) { 846 return decodeURIComponent(escape(charenc.bin.bytesToString(bytes))); 847 } 848 }, 849 850 // Binary encoding 851 bin: { 852 // Convert a string to a byte array 853 stringToBytes: function(str) { 854 for (var bytes = [], i = 0; i < str.length; i++) 855 bytes.push(str.charCodeAt(i) & 0xFF); 856 return bytes; 857 }, 858 859 // Convert a byte array to a string 860 bytesToString: function(bytes) { 861 for (var str = [], i = 0; i < bytes.length; i++) 862 str.push(String.fromCharCode(bytes[i])); 863 return str.join(''); 864 } 865 } 866 }; 867 868 var charenc_1 = charenc; 869 870 /*! 871 * Determine if an object is a Buffer 872 * 873 * @author Feross Aboukhadijeh <https://feross.org> 874 * @license MIT 875 */ 876 877 // The _isBuffer check is for Safari 5-7 support, because it's missing 878 // Object.prototype.constructor. Remove this eventually 879 var isBuffer_1 = function (obj) { 880 return obj != null && (isBuffer(obj) || isSlowBuffer(obj) || !!obj._isBuffer) 881 }; 882 883 function isBuffer (obj) { 884 return !!obj.constructor && typeof obj.constructor.isBuffer === 'function' && obj.constructor.isBuffer(obj) 885 } 886 887 // For Node v0.10 support. Remove this eventually. 888 function isSlowBuffer (obj) { 889 return typeof obj.readFloatLE === 'function' && typeof obj.slice === 'function' && isBuffer(obj.slice(0, 0)) 890 } 891 892 (function(){ 893 var crypt = cryptExports, 894 utf8 = charenc_1.utf8, 895 isBuffer = isBuffer_1, 896 bin = charenc_1.bin, 897 898 // The core 899 md5 = function (message, options) { 900 // Convert to byte array 901 if (message.constructor == String) 902 if (options && options.encoding === 'binary') 903 message = bin.stringToBytes(message); 904 else 905 message = utf8.stringToBytes(message); 906 else if (isBuffer(message)) 907 message = Array.prototype.slice.call(message, 0); 908 else if (!Array.isArray(message) && message.constructor !== Uint8Array) 909 message = message.toString(); 910 // else, assume byte array already 911 912 var m = crypt.bytesToWords(message), 913 l = message.length * 8, 914 a = 1732584193, 915 b = -271733879, 916 c = -1732584194, 917 d = 271733878; 918 919 // Swap endian 920 for (var i = 0; i < m.length; i++) { 921 m[i] = ((m[i] << 8) | (m[i] >>> 24)) & 0x00FF00FF | 922 ((m[i] << 24) | (m[i] >>> 8)) & 0xFF00FF00; 923 } 924 925 // Padding 926 m[l >>> 5] |= 0x80 << (l % 32); 927 m[(((l + 64) >>> 9) << 4) + 14] = l; 928 929 // Method shortcuts 930 var FF = md5._ff, 931 GG = md5._gg, 932 HH = md5._hh, 933 II = md5._ii; 934 935 for (var i = 0; i < m.length; i += 16) { 936 937 var aa = a, 938 bb = b, 939 cc = c, 940 dd = d; 941 942 a = FF(a, b, c, d, m[i+ 0], 7, -680876936); 943 d = FF(d, a, b, c, m[i+ 1], 12, -389564586); 944 c = FF(c, d, a, b, m[i+ 2], 17, 606105819); 945 b = FF(b, c, d, a, m[i+ 3], 22, -1044525330); 946 a = FF(a, b, c, d, m[i+ 4], 7, -176418897); 947 d = FF(d, a, b, c, m[i+ 5], 12, 1200080426); 948 c = FF(c, d, a, b, m[i+ 6], 17, -1473231341); 949 b = FF(b, c, d, a, m[i+ 7], 22, -45705983); 950 a = FF(a, b, c, d, m[i+ 8], 7, 1770035416); 951 d = FF(d, a, b, c, m[i+ 9], 12, -1958414417); 952 c = FF(c, d, a, b, m[i+10], 17, -42063); 953 b = FF(b, c, d, a, m[i+11], 22, -1990404162); 954 a = FF(a, b, c, d, m[i+12], 7, 1804603682); 955 d = FF(d, a, b, c, m[i+13], 12, -40341101); 956 c = FF(c, d, a, b, m[i+14], 17, -1502002290); 957 b = FF(b, c, d, a, m[i+15], 22, 1236535329); 958 959 a = GG(a, b, c, d, m[i+ 1], 5, -165796510); 960 d = GG(d, a, b, c, m[i+ 6], 9, -1069501632); 961 c = GG(c, d, a, b, m[i+11], 14, 643717713); 962 b = GG(b, c, d, a, m[i+ 0], 20, -373897302); 963 a = GG(a, b, c, d, m[i+ 5], 5, -701558691); 964 d = GG(d, a, b, c, m[i+10], 9, 38016083); 965 c = GG(c, d, a, b, m[i+15], 14, -660478335); 966 b = GG(b, c, d, a, m[i+ 4], 20, -405537848); 967 a = GG(a, b, c, d, m[i+ 9], 5, 568446438); 968 d = GG(d, a, b, c, m[i+14], 9, -1019803690); 969 c = GG(c, d, a, b, m[i+ 3], 14, -187363961); 970 b = GG(b, c, d, a, m[i+ 8], 20, 1163531501); 971 a = GG(a, b, c, d, m[i+13], 5, -1444681467); 972 d = GG(d, a, b, c, m[i+ 2], 9, -51403784); 973 c = GG(c, d, a, b, m[i+ 7], 14, 1735328473); 974 b = GG(b, c, d, a, m[i+12], 20, -1926607734); 975 976 a = HH(a, b, c, d, m[i+ 5], 4, -378558); 977 d = HH(d, a, b, c, m[i+ 8], 11, -2022574463); 978 c = HH(c, d, a, b, m[i+11], 16, 1839030562); 979 b = HH(b, c, d, a, m[i+14], 23, -35309556); 980 a = HH(a, b, c, d, m[i+ 1], 4, -1530992060); 981 d = HH(d, a, b, c, m[i+ 4], 11, 1272893353); 982 c = HH(c, d, a, b, m[i+ 7], 16, -155497632); 983 b = HH(b, c, d, a, m[i+10], 23, -1094730640); 984 a = HH(a, b, c, d, m[i+13], 4, 681279174); 985 d = HH(d, a, b, c, m[i+ 0], 11, -358537222); 986 c = HH(c, d, a, b, m[i+ 3], 16, -722521979); 987 b = HH(b, c, d, a, m[i+ 6], 23, 76029189); 988 a = HH(a, b, c, d, m[i+ 9], 4, -640364487); 989 d = HH(d, a, b, c, m[i+12], 11, -421815835); 990 c = HH(c, d, a, b, m[i+15], 16, 530742520); 991 b = HH(b, c, d, a, m[i+ 2], 23, -995338651); 992 993 a = II(a, b, c, d, m[i+ 0], 6, -198630844); 994 d = II(d, a, b, c, m[i+ 7], 10, 1126891415); 995 c = II(c, d, a, b, m[i+14], 15, -1416354905); 996 b = II(b, c, d, a, m[i+ 5], 21, -57434055); 997 a = II(a, b, c, d, m[i+12], 6, 1700485571); 998 d = II(d, a, b, c, m[i+ 3], 10, -1894986606); 999 c = II(c, d, a, b, m[i+10], 15, -1051523); 1000 b = II(b, c, d, a, m[i+ 1], 21, -2054922799); 1001 a = II(a, b, c, d, m[i+ 8], 6, 1873313359); 1002 d = II(d, a, b, c, m[i+15], 10, -30611744); 1003 c = II(c, d, a, b, m[i+ 6], 15, -1560198380); 1004 b = II(b, c, d, a, m[i+13], 21, 1309151649); 1005 a = II(a, b, c, d, m[i+ 4], 6, -145523070); 1006 d = II(d, a, b, c, m[i+11], 10, -1120210379); 1007 c = II(c, d, a, b, m[i+ 2], 15, 718787259); 1008 b = II(b, c, d, a, m[i+ 9], 21, -343485551); 1009 1010 a = (a + aa) >>> 0; 1011 b = (b + bb) >>> 0; 1012 c = (c + cc) >>> 0; 1013 d = (d + dd) >>> 0; 1014 } 1015 1016 return crypt.endian([a, b, c, d]); 1017 }; 1018 1019 // Auxiliary functions 1020 md5._ff = function (a, b, c, d, x, s, t) { 1021 var n = a + (b & c | ~b & d) + (x >>> 0) + t; 1022 return ((n << s) | (n >>> (32 - s))) + b; 1023 }; 1024 md5._gg = function (a, b, c, d, x, s, t) { 1025 var n = a + (b & d | c & ~d) + (x >>> 0) + t; 1026 return ((n << s) | (n >>> (32 - s))) + b; 1027 }; 1028 md5._hh = function (a, b, c, d, x, s, t) { 1029 var n = a + (b ^ c ^ d) + (x >>> 0) + t; 1030 return ((n << s) | (n >>> (32 - s))) + b; 1031 }; 1032 md5._ii = function (a, b, c, d, x, s, t) { 1033 var n = a + (c ^ (b | ~d)) + (x >>> 0) + t; 1034 return ((n << s) | (n >>> (32 - s))) + b; 1035 }; 1036 1037 // Package private blocksize 1038 md5._blocksize = 16; 1039 md5._digestsize = 16; 1040 1041 md5$1.exports = function (message, options) { 1042 if (message === undefined || message === null) 1043 throw new Error('Illegal argument ' + message); 1044 1045 var digestbytes = crypt.wordsToBytes(md5(message, options)); 1046 return options && options.asBytes ? digestbytes : 1047 options && options.asString ? bin.bytesToString(digestbytes) : 1048 crypt.bytesToHex(digestbytes); 1049 }; 1050 1051 })(); 1052 1053 var md5Exports = md5$1.exports; 1054 var md5 = /*@__PURE__*/getDefaultExportFromCjs(md5Exports); 1055 1056 /** 1057 * Notice: all nullable properties must be set till the checkout. 1058 * 1059 * @since 1.0 1060 */ 1061 class CartItem { 1062 /** 1063 * @since 1.4.0 1064 * @access protected 1065 */ 1066 setupProperties() { 1067 /** 1068 * @since 1.4.0 1069 * @var {String} 1070 * @access protected 1071 */ 1072 this.itemId = ''; 1073 1074 /** 1075 * @since 1.4.0 1076 * @var {Service|Null} 1077 */ 1078 this.service = null; 1079 this.serviceCategories = {}; 1080 1081 /** 1082 * @since 1.4.0 1083 * @var {Employee|Null} 1084 */ 1085 this.employee = null; 1086 1087 /** 1088 * @since 1.4.0 1089 * @var {Location|Null} 1090 */ 1091 this.location = null; 1092 1093 /** 1094 * @since 1.0 1095 * @var {Date|Null} 1096 */ 1097 this.date = null; 1098 1099 /** 1100 * @since 1.0 1101 * @var {TimePeriod|Null} 1102 */ 1103 this.time = null; 1104 1105 /** 1106 * @since 1.0 1107 * @var {Number} 1108 */ 1109 this.capacity = 1; 1110 1111 /** 1112 * An array of all available employees. For example, when no exact 1113 * employee was selected on the previous steps ("- Any -"). 1114 * 1115 * @since 1.4.0 1116 * @var {Employee[]} 1117 */ 1118 this.availableEmployees = []; 1119 1120 /** 1121 * An array of all available locations. For example, when no exact 1122 * location was selected on the previous steps ("- Any -"). 1123 * 1124 * @since 1.4.0 1125 * @var {Location[]} 1126 */ 1127 this.availableLocations = []; 1128 } 1129 1130 /** 1131 * @since 1.0 1132 * 1133 * @param {String} itemId 1134 */ 1135 constructor(itemId) { 1136 this.setupProperties(); 1137 this.itemId = itemId; 1138 } 1139 1140 /** 1141 * @since 1.4.0 1142 * 1143 * @return {String} 1144 */ 1145 getItemId() { 1146 return this.itemId; 1147 } 1148 1149 /** 1150 * @since 1.4.0 1151 * 1152 * @return {Number[]} 1153 */ 1154 getAvailableEmployeeIds() {
1155 return this.availableEmployees.map(entity => entity.id); 1156 } 1157 1158 /** 1159 * @since 1.4.0 1160 * 1161 * @return {Number[]} 1162 */ 1163 getAvailableLocationIds() { 1164 return this.availableLocations.map(entity => entity.id); 1165 } 1166 1167 /** 1168 * @since 1.4.0 1169 * 1170 * @return {Object} 1171 */ 1172 getAvailableIds() { 1173 return { 1174 // service and employee can impact to time slots availability 1175 service_id: this.service !== null ? this.service.id : 0, 1176 employee_id: this.employee !== null ? this.employee.id : 0, 1177 employee_ids: this.getAvailableEmployeeIds(), 1178 location_ids: this.getAvailableLocationIds() 1179 }; 1180 } 1181 1182 /** 1183 * @since 1.0 1184 * 1185 * @return {Object} 1186 */ 1187 getIds() { 1188 return { 1189 service_id: this.service !== null ? this.service.id : 0, 1190 employee_id: this.employee !== null ? this.employee.id : 0, 1191 location_id: this.location !== null ? this.location.id : 0 1192 }; 1193 } 1194 1195 /** 1196 * @since 1.0 1197 * @since 1.4.0 added the <code>fields</code> argument. 1198 * 1199 * @param {String} fields Optional. all|ids|period|availability. 'all' by default. 1200 * @return {Object} 1201 */ 1202 toArray(fields = 'all') { 1203 if (fields === 'ids') { 1204 return this.getIds(); 1205 } else if (fields === 'availability') { 1206 return this.getAvailableIds(); 1207 } else if (fields === 'period') { 1208 return { 1209 date: this.date !== null ? mpa_format_date(this.date, 'internal') : '', 1210 time: this.time !== null ? this.time.toString('internal') : '' 1211 }; 1212 } else { 1213 return jQuery.extend(this.getIds(), { 1214 date: this.date !== null ? mpa_format_date(this.date, 'internal') : '', 1215 time: this.time !== null ? this.time.toString('internal') : '', 1216 capacity: this.capacity 1217 }); 1218 } 1219 } 1220 1221 /** 1222 * @since 1.4.0 1223 * 1224 * @param {String} fields Optional. all|ids|period. 'all' by default. 1225 * @return {Boolean} 1226 */ 1227 isSet(fields = 'all') { 1228 let isSet = true; 1229 if (fields === 'all' || fields === 'ids') { 1230 isSet = isSet && this.service !== null && this.employee !== null && this.location !== null; 1231 } 1232 if (fields === 'all' || fields === 'period') { 1233 isSet = isSet && this.date !== null && this.time !== null; 1234 } 1235 return isSet; 1236 } 1237 1238 /** 1239 * @since 1.11.0 1240 * 1241 * @return {Number} 1242 */ 1243 getPrice() { 1244 if (!this.service) { 1245 return 0; 1246 } 1247 let employeeId = this.employee ? this.employee.id : 0; 1248 return this.service.getPrice(employeeId, this.capacity); 1249 } 1250 1251 /** 1252 * @since 1.14.0 1253 * 1254 * @return {Number} 1255 * @param {number} priceAfterPossibleDiscount 1256 */ 1257 getDeposit(priceAfterPossibleDiscount) { 1258 let deposit = 0; 1259 switch (this.service.depositType) { 1260 case 'disabled': 1261 deposit = priceAfterPossibleDiscount; 1262 break; 1263 case 'fixed': 1264 deposit = this.service.depositAmount; 1265 break; 1266 case 'percentage': 1267 deposit = priceAfterPossibleDiscount * this.service.depositAmount / 100; 1268 break; 1269 default: 1270 deposit = priceAfterPossibleDiscount; 1271 } 1272 if (deposit > priceAfterPossibleDiscount) { 1273 return priceAfterPossibleDiscount; 1274 } 1275 return deposit; 1276 } 1277 1278 /** 1279 * @since 1.0 1280 * 1281 * @param {String} fields Optional. all|ids|availability. 'all' by default. 1282 * @return {String} Snapshot MD5 hash. 1283 */ 1284 getHash(fields = 'all') { 1285 return md5(JSON.stringify(this.toArray(fields))); 1286 } 1287 1288 /** 1289 * @since 1.0 1290 * 1291 * @param {String} hash Last saved snapshot. 1292 * @param {String} fields Optional. all|ids|availability. 'all' by default. 1293 * @return {Boolean} 1294 */ 1295 didChange(hash, fields = 'all') { 1296 return hash !== this.getHash(fields); 1297 } 1298 1299 /** 1300 * @since 1.4.0 1301 * 1302 * @param {Number} employeeId 1303 * @return {Employee|Null} 1304 */ 1305 getEmployee(employeeId) { 1306 if (this.employee !== null && this.employee.id == employeeId) { 1307 return this.employee; 1308 } else { 1309 for (let employee of this.availableEmployees) { 1310 if (employee.id == employeeId) { 1311 return employee; 1312 } 1313 } 1314 } 1315 return null; 1316 } 1317 1318 /** 1319 * @since 1.4.0 1320 * 1321 * @param {Number} locationId 1322 * @return {Location|Null} 1323 */ 1324 getLocation(locationId) { 1325 if (this.location !== null && this.location.id == locationId) { 1326 return this.location; 1327 } else { 1328 for (let location of this.availableLocations) { 1329 if (location.id == locationId) { 1330 return location; 1331 } 1332 } 1333 } 1334 return null; 1335 } 1336 1337 /** 1338 * @since 1.4.0 1339 * 1340 * @return {Boolean} 1341 */ 1342 hasMultipleAvailableEmployees() { 1343 return this.availableEmployees.length > 1; 1344 } 1345 1346 /** 1347 * @since 1.4.0 1348 * 1349 * @return {Boolean} 1350 */ 1351 hasMultipleAvailableLocations() { 1352 return this.availableLocations.length > 1; 1353 } 1354 1355 /** 1356 * @since 1.4.0 1357 * 1358 * @return {Boolean} 1359 */ 1360 hasMultipleAvailableVariants() { 1361 return this.hasMultipleAvailableEmployees() || this.hasMultipleAvailableLocations(); 1362 } 1363 1364 /** 1365 * @since 1.4.0 1366 * 1367 * @param {Service} service 1368 */ 1369 setService(service) { 1370 this.service = service; 1371 } 1372 1373 /** 1374 * @param {category slug: category name, ...} serviceCategories 1375 */ 1376 setServiceCategories(serviceCategories) { 1377 this.serviceCategories = serviceCategories; 1378 } 1379 1380 /** 1381 * @since 1.4.0 1382 * 1383 * @param {Employee|Number} employee Employee object or ID. 1384 * @param {Boolean} resetAvailable Optional. True by default. 1385 */ 1386 setEmployee(employee, resetAvailable = true) { 1387 if (typeof employee === 'number') { 1388 employee = this.getEmployee(employee); 1389 } 1390 this.employee = employee; 1391 if (resetAvailable === true) { 1392 this.availableEmployees = [employee]; 1393 } 1394 } 1395 1396 /** 1397 * @since 1.4.0 1398 * 1399 * @param {Employee[]} availableEmployees 1400 * @param {Boolean} resetEmployee Optional. True by default. 1401 */ 1402 setAvailableEmployees(availableEmployees, resetEmployee = true) { 1403 this.availableEmployees = availableEmployees; 1404 if (resetEmployee === true) { 1405 this.employee = null; 1406 } 1407 } 1408 1409 /** 1410 * @since 1.4.0 1411 * 1412 * @param {Location|Number} location Location object or ID. 1413 * @param {Boolean} resetAvailable Optional. True by default. 1414 */ 1415 setLocation(location, resetAvailable = true) { 1416 if (typeof location === 'number') { 1417 location = this.getLocation(location); 1418 } 1419 this.location = location; 1420 if (resetAvailable === true) { 1421 this.availableLocations = [location]; 1422 } 1423 } 1424 1425 /** 1426 * @since 1.4.0 1427 * 1428 * @param {Location[]} availableLocations 1429 * @param {Boolean} resetLocation Optional. True by default. 1430 */ 1431 setAvailableLocations(availableLocations, resetLocation = true) { 1432 this.availableLocations = availableLocations;
1433 if (resetLocation === true) { 1434 this.location = null; 1435 } 1436 } 1437 } 1438 1439 /** 1440 * @param {Array} array 1441 * @return {Array} 1442 * 1443 * @since 1.0 1444 */ 1445 function mpa_array_unique(array) { 1446 let unique = array.filter((value, index, self) => { 1447 return self.indexOf(value) === index; 1448 }); 1449 return unique; 1450 } 1451 1452 /** 1453 * @param {Array} array1 1454 * @param {Array} array2 1455 * @return {Array} 1456 * 1457 * @since 1.2.1 1458 */ 1459 function mpa_array_intersect(array1, array2) { 1460 return array1.filter(value => array2.indexOf(value) != -1); 1461 } 1462 1463 /** 1464 * @since 1.4.0 1465 * 1466 * @param {Array} keys 1467 * @param {Array} values 1468 * @return {Object} 1469 */ 1470 function mpa_array_combine(keys, values) { 1471 let size = Math.min(keys.length, values.length); 1472 let object = {}; 1473 for (let i = 0; i < size; i++) { 1474 object[keys[i]] = values[i]; 1475 } 1476 return object; 1477 } 1478 1479 /** 1480 * @since 1.4.0 1481 * 1482 * @param {Number} start 1483 * @param {Number} end 1484 * @param {Number} step Optional. 1 by default. 1485 * @return {Array} An array of numbers from <code>start</code> to <code>end</code> 1486 * with step <code>step</code>. 1487 */ 1488 function mpa_array_range(start, end, step = 1) { 1489 let validStep = step || 1; // No 0 1490 let length = Math.abs(Math.floor((end - start) / validStep)) + 1; 1491 let range = [...Array(length).keys()].map(i => i * step + start); 1492 return range; 1493 } 1494 1495 /** 1496 * @since 1.0 1497 */ 1498 class Map { 1499 /** 1500 * @param {Object|Null} values Optional. Starting values. Null by default. 1501 * 1502 * @since 1.0 1503 */ 1504 constructor(values = null) { 1505 this.setupProperties(); 1506 if (values != null) { 1507 this.merge(values); 1508 } 1509 } 1510 1511 /** 1512 * @access protected 1513 * 1514 * @since 1.0 1515 */ 1516 setupProperties() { 1517 this.keys = []; 1518 this.values = {}; 1519 this.length = 0; 1520 } 1521 1522 /** 1523 * @param {Object} values 1524 * 1525 * @since 1.0 1526 */ 1527 merge(values) { 1528 for (let key in values) { 1529 this.push(key, values[key]); 1530 } 1531 } 1532 1533 /** 1534 * @param {String|Number|Symbol} key 1535 * @param {*} value 1536 * @return {Boolean} Is this a new item (key) or a duplicate. 1537 * 1538 * @since 1.0 1539 */ 1540 push(key, value) { 1541 let isNew = !this.includesKey(key); 1542 1543 // Add value 1544 this.values[key] = value; 1545 1546 // Add key 1547 if (isNew) { 1548 this.keys.push(key); 1549 this.length++; 1550 } 1551 return isNew; 1552 } 1553 1554 /** 1555 * @param {String|Number|Symbol} key 1556 * @param {*} defaultValue Optional. Null by default. 1557 * @return {*} Item value or default value. 1558 * 1559 * @since 1.0 1560 */ 1561 find(key, defaultValue = null) { 1562 if (this.includesKey(key)) { 1563 return this.values[key]; 1564 } else { 1565 return defaultValue; 1566 } 1567 } 1568 1569 /** 1570 * @param {String|Number|Symbol} key 1571 * @param {*} defaultValue Optional. Null by default. 1572 * @return {*} Next item value or default value. 1573 * 1574 * @since 1.9.0 1575 */ 1576 findNext(key, defaultValue = null) { 1577 let nextKey = this.findNextKey(key); 1578 if (nextKey !== '') { 1579 return this.values[nextKey]; 1580 } else { 1581 return defaultValue; 1582 } 1583 } 1584 1585 /** 1586 * @param {String|Number|Symbol} key 1587 * @return {String|Number|Symbol} Next or last key. 1588 * 1589 * @since 1.9.0 1590 */ 1591 findNextKey(key) { 1592 let index = this.keys.indexOf(key); 1593 if (index === -1) { 1594 return ''; 1595 } 1596 let nextIndex = index + 1; 1597 let nextKey = nextIndex < this.length ? this.keys[nextIndex] : this.keys[index]; 1598 return nextKey; 1599 } 1600 1601 /** 1602 * @param {String|Number|Symbol} key 1603 * @param {*} defaultValue Optional. Null by default. 1604 * @return {*} Previous item value or default value. 1605 * 1606 * @since 1.9.0 1607 */ 1608 findPrevious(key, defaultValue = null) { 1609 let previousKey = this.findPreviousKey(key); 1610 if (previousKey !== '') { 1611 return this.values[previousKey]; 1612 } else { 1613 return defaultValue; 1614 } 1615 } 1616 1617 /** 1618 * @param {String|Number|Symbol} key 1619 * @return {String|Number|Symbol} Previous or first key. 1620 * 1621 * @since 1.9.0 1622 */ 1623 findPreviousKey(key) { 1624 let index = this.keys.indexOf(key); 1625 if (index === -1) { 1626 return ''; 1627 } 1628 let previousIndex = index - 1; 1629 let previousKey = previousIndex >= 0 ? this.keys[previousIndex] : this.keys[index]; 1630 return previousKey; 1631 } 1632 1633 /** 1634 * Currently it's an alias of push() and has no difference. 1635 * 1636 * @param {String|Number|Symbol} key 1637 * @param {*} value 1638 * @return {Boolean}
1638 Is this a new item (key) or a duplicate. 1639 * 1640 * @since 1.0 1641 */ 1642 update(key, value) { 1643 return this.push(key, value); 1644 } 1645 1646 /** 1647 * @param {String|Number|Symbol} key 1648 * @return {*} The removed value or null if there was no item with a such key. 1649 * 1650 * @since 1.0 1651 */ 1652 remove(key) { 1653 if (!this.includesKey(key)) { 1654 return null; 1655 } 1656 let value = this.values[key]; 1657 1658 // Remove value 1659 delete this.values[key]; 1660 1661 // Remove key 1662 let index = this.keys.indexOf(key); 1663 this.keys.splice(index, 1); 1664 1665 // Update length 1666 this.length--; 1667 return value; 1668 } 1669 1670 /** 1671 * @return {Map} 1672 * 1673 * @since 1.0 1674 */ 1675 empty() { 1676 this.keys = []; 1677 this.values = {}; 1678 this.length = 0; 1679 return this; 1680 } 1681 1682 /** 1683 * @return {Boolean} 1684 * 1685 * @since 1.0 1686 */ 1687 isEmpty() { 1688 return this.length == 0; 1689 } 1690 1691 /** 1692 * @param {String|Number|Symbol} key 1693 * @return {Boolean} 1694 * 1695 * @since 1.0 1696 */ 1697 includesKey(key) { 1698 return key in this.values; 1699 } 1700 1701 /** 1702 * @since 1.6.2 1703 * 1704 * @return {String|null} 1705 */ 1706 firstKey() { 1707 if (this.keys.length > 0) { 1708 return this.keys[0]; 1709 } else { 1710 return null; 1711 } 1712 } 1713 1714 /** 1715 * @since 1.6.2 1716 * 1717 * @return {*|null} 1718 */ 1719 firstValue() { 1720 let firstKey = this.firstKey(); 1721 if (firstKey !== null) { 1722 return this.values[firstKey]; 1723 } else { 1724 return null; 1725 } 1726 } 1727 1728 /** 1729 * @return {*} 1730 * 1731 * @since 1.0 1732 */ 1733 lastValue() { 1734 let lastKey = this.lastKey(); 1735 if (lastKey != null) { 1736 return this.values[lastKey]; 1737 } else { 1738 return null; 1739 } 1740 } 1741 1742 /** 1743 * @return {String|Number|Symbol|Null} 1744 * 1745 * @since 1.0 1746 */ 1747 lastKey() { 1748 if (!this.isEmpty()) { 1749 return this.keys[this.length - 1]; 1750 } else { 1751 return null; 1752 } 1753 } 1754 1755 /** 1756 * @return {Array} 1757 * 1758 * @since 1.0 1759 */ 1760 cloneKeys() { 1761 return [...this.keys]; 1762 } 1763 1764 /** 1765 * @param {String|Number|Symbol} column 1766 * @return {Array} Column values (only unique ones). 1767 * 1768 * @since 1.0 1769 */ 1770 getColumn(column) { 1771 let values = []; 1772 for (let key of this.keys) { 1773 let value = this.values[key][column]; 1774 if (value != undefined) { 1775 if (Array.isArray(value)) { 1776 // Merge arrays 1777 values = values.concat(value); 1778 } else { 1779 // Add value to array 1780 values.push(value); 1781 } 1782 } 1783 } 1784 return mpa_array_unique(values); 1785 } 1786 1787 /** 1788 * @param {Function} callback 1789 * 1790 * @since 1.0 1791 */ 1792 forEach(callback) { 1793 let i = 0; 1794 for (let key of this.keys) { 1795 let response = callback(this.values[key], i, key, this); 1796 i++; 1797 if (response === false) { 1798 break; 1799 } 1800 } 1801 } 1802 1803 /** 1804 * @param {Function} callback 1805 * @return {Array} May have duplicates. 1806 * 1807 * @since 1.0 1808 */ 1809 map(callback) { 1810 let values = []; 1811 let i = 0; 1812 for (let key of this.keys) { 1813 values.push(callback(this.values[key], i, key, this)); 1814 i++; 1815 } 1816 return values; 1817 } 1818 1819 /** 1820 * @return {Array} 1821 * 1822 * @since 1.0 1823 */ 1824 toArray() { 1825 let values = []; 1826 for (let key of this.keys) { 1827 values.push(this.values[key]); 1828 } 1829 return values; 1830 } 1831 } 1832 1833 let globals = {}; 1834 1835 /** 1836 * @param {*} value 1837 * @param {Boolean} strict Optional. Don't count non-iterable types. False by 1838 * default. 1839 * @return {Number} 1840 * 1841 * @since 1.0 1842 */ 1843 function mpa_count(value, strict = false) { 1844 if (typeof value == 'object') { 1845 if (Array.isArray(value)) { 1846 return value.length; 1847 } else { 1848 return Object.keys(value).length; 1849 } 1850 } else { 1851 return strict ? 0 : 1; 1852 } 1853 } 1854 1855 /** 1856 * @param {*} value 1857 * @param {Boolean} strict Optional. False by default. 1858 * @return {Boolean} 1859 * 1860 * @since 1.0 1861 */ 1862 function mpa_empty(value, strict = false) { 1863 if (typeof value == 'object') { 1864 return mpa_count(value) == 0; 1865 } else { 1866 return strict ? true : !value; 1867 } 1868 } 1869 1870 /** 1871 * @param {String} prefix Optional. '' by default. 1872 * @param {Boolean} moreEntropy Optional. False by default. 1873 * @return {String} 1874 * 1875 * @see https://locutus.io/php/misc/uniqid/ 1876 * 1877 * @since 1.0 1878 */ 1879 function mpa_uniqid(prefix = '', moreEntropy = false) { 1880 let mpa_format_seed = function (seed, requiredWidth) { 1881 seed = parseInt(seed, 10).toString(16); // To hex string 1882 1883 if (requiredWidth < seed.length) { 1884 // So long we split 1885 return seed.slice(seed.length - requiredWidth); 1886 } else if (requiredWidth > seed.length) { 1887 // So short we pad 1888 return Array(1 + (requiredWidth - seed.length)).join('0') + seed; 1889 } else { 1890 // Exact width 1891 return seed; 1892 } 1893 }; 1894 if (!globals.uniqid_seed) { 1895 // Init seed with big random int 1896 globals.uniqid_seed = Math.floor(Math.random() * 0x75bcd15); 1897 } 1898 globals.uniqid_seed++; 1899 1900 // Start with prefix 1901 let id = prefix; 1902 // Add current milliseconds (hex string) 1903 id += mpa_format_seed(parseInt(new Date().getTime() / 1000, 10), 8); 1904 // Add seed hex string 1905 id += mpa_format_seed(globals.uniqid_seed, 5); 1906 if (moreEntropy) { 1907 // For more entropy we add a float lowe to 10 1908 id += (Math.random() * 10).toFixed(8).toString(); 1909 } 1910 return id; 1911 } 1912 1913 /** 1914 * @since 1.0 1915 */ 1916 class Cart { 1917 /** 1918 * @since 1.4.0 1919 * @access protected 1920 */ 1921 setupProperties() { 1922 /** 1923 * @since 1.0 1924 * @var {Map} 1925 */ 1926 this.items = new Map(); 1927 1928 /** 1929 * @since 1.4.0 (Replaced the property <code>selectedItem</code>) 1930 * @var {CartItem|Null} 1931 */ 1932 this.activeItem = null; 1933 1934 /** 1935 * @since 1.0 1936 * @var {Object} 1937 */ 1938 this.customerDetails = { 1939 name: '', 1940 email: '', 1941 phone: '' 1942 }; 1943 1944 /** 1945 * @since 1.5.0 1946 * @var {Object} 1947 */ 1948 this.paymentDetails = { 1949 booking_id: 0, 1950 gateway_id: 'none' 1951 }; 1952 1953 /** 1954 * @since 1.11.0 1955 * @var {Coupon|Null} 1956 */ 1957 this.coupon = null; 1958 } 1959 1960 /** 1961 * @since 1.0 1962 */ 1963 constructor() { 1964 this.setupProperties(); 1965 } 1966 1967 /** 1968 * @since 1.0 1969 * @since 1.4.0 added the <code>itemId</code> argument. 1970 * @since 1.4.0 returns <code>CartItem</code> object instead of item identifier. 1971 * 1972 * @param {String} itemId Optional. 1973 * @return {CartItem} New item. 1974 */ 1975 createItem(itemId = '') { 1976 if (!itemId) { 1977 itemId = mpa_uniqid(); 1978 } 1979 let item = new CartItem(itemId); 1980 this.items.push(itemId, item); 1981 this.activeItem = item; 1982 return item; 1983 } 1984 1985 /** 1986 * @since 1.0 1987 * 1988 * @param {String} itemId 1989 * @return {CartItem|Null} 1990 */ 1991 getItem(itemId) { 1992 return this.items.find(itemId); 1993 } 1994 1995 /** 1996 * @since 1.4.0 1997 * 1998 * @return {CartItem|Null} 1999 */ 2000 getActiveItem() { 2001 return this.activeItem; 2002 } 2003 2004 /** 2005 * @since 1.4.0 2006 * 2007 * @return {String} 2008 */ 2009 getActiveItemId() { 2010 if (this.activeItem !== null) { 2011 return this.activeItem.getItemId(); 2012 } else { 2013 return ''; 2014 } 2015 } 2016 2017 /** 2018 * @since 1.4.0 2019 * 2020 * @return {Number} 2021 */ 2022 getItemsCount() { 2023 return this.items.length; 2024 } 2025 2026 /** 2027 * @since 1.4.0 2028 * 2029 * @param {String|CartItem} item 2030 */ 2031 setActiveItem(item) { 2032 if (typeof item === 'string') { 2033 this.activeItem = this.getItem(item); 2034 } else { 2035 this.activeItem = item; 2036 } 2037 } 2038 2039 /** 2040 * @since 1.4.0 2041 * 2042 * @param {String|CartItem} item 2043 */ 2044 removeItem(item) { 2045 if (typeof item === 'string') { 2046 this.items.remove(item); 2047 } else { 2048 this.items.remove(item.getItemId()); 2049 } 2050 } 2051 2052 /** 2053 * @since 1.4.0 2054 * 2055 * @return {Boolean} 2056 */ 2057 isEmpty() { 2058 return this.getItemsCount() === 0; 2059 } 2060 2061 /** 2062 * @since 1.4.0 2063 * 2064 * @return {Object} {Product name: Product price} 2065 */ 2066 getProductPrices() { 2067 let products = [];
2068 this.items.forEach(cartItem => { 2069 if (cartItem.service != null) { 2070 products.push({ 2071 name: cartItem.service.name, 2072 price: cartItem.getPrice() 2073 }); 2074 } 2075 }); 2076 return products; 2077 } 2078 2079 /** 2080 * @since 1.4.0 2081 * 2082 * @param {Array|Null} products Optional. List of products to calculate the 2083 * subtotal price. Products of the cart by default. 2084 * @return {Number} 2085 */ 2086 getSubtotalPrice(products = null) { 2087 if (products === null) { 2088 products = this.getProductPrices(); 2089 } 2090 let subtotal = 0; 2091 for (let product of products) { 2092 subtotal += product.price; 2093 } 2094 return subtotal; 2095 } 2096 2097 /** 2098 * @since 1.4.0 2099 * 2100 * @param {Object|Null} products Optional. List of products to calculate the 2101 * total price. Products of the cart by default. 2102 * @return {Number} 2103 */ 2104 getTotalPrice(products = null) { 2105 let subtotalPrice = this.getSubtotalPrice(products); 2106 if (this.hasCoupon()) { 2107 let discountPrice = this.coupon.calcDiscountAmount(this); 2108 return Math.max(0, subtotalPrice - discountPrice); 2109 } else { 2110 return subtotalPrice; 2111 } 2112 } 2113 2114 /** 2115 * @since 1.14.0 2116 * @return {Number} 2117 */ 2118 getDeposit() { 2119 let deposit = 0; 2120 this.items.forEach(cartItem => { 2121 // TODO: ask all prices via ajax call to avoid calculation duplication 2122 let price = cartItem.getPrice(); 2123 if (this.hasCoupon()) { 2124 price = price - this.coupon.calcDiscountForCartItem(cartItem); 2125 } 2126 deposit += cartItem.getDeposit(price); 2127 }); 2128 return deposit; 2129 } 2130 2131 /** 2132 * @since 1.5.0 2133 * 2134 * @return {Object} 2135 */ 2136 getCustomer() { 2137 return this.customerDetails; 2138 } 2139 2140 /** 2141 * @since 1.4.0 2142 * 2143 * @return {Object} 2144 */ 2145 getOrder() { 2146 let products = this.getProductPrices(); 2147 let order = { 2148 products: products, 2149 subtotal: this.getSubtotalPrice(products), 2150 total: this.getTotalPrice(products), 2151 customer: this.getCustomer() 2152 }; 2153 if (this.hasCoupon()) { 2154 order['coupon'] = { 2155 code: this.coupon.getCode(), 2156 amount: this.coupon.calcDiscountAmount(this) 2157 }; 2158 } 2159 order['deposit'] = this.getDeposit(); 2160 return order; 2161 } 2162 2163 /** 2164 * @since 1.5.0 2165 * 2166 * @return {Object} 2167 */ 2168 getPaymentDetails() { 2169 return this.paymentDetails; 2170 } 2171 2172 /** 2173 * @since 1.0 2174 * @since 1.4.0 added the <code>fields</code> argument. 2175 * 2176 * @param {String} fields Optional. all|items. 'all' by default. 2177 * @return {Object|Array} 2178 */ 2179 toArray(fields = 'all') { 2180 let cart = { 2181 items: [], 2182 customer: this.customerDetails 2183 }; 2184 2185 // Add items 2186 this.items.forEach(cartItem => { 2187 if (!cartItem.isSet()) { 2188 return; 2189 } 2190 cart.items.push(cartItem.toArray()); 2191 }); 2192 2193 // Add payment details 2194 if (mpapp().settings().isPaymentsEnabled()) { 2195 cart.payment_details = this.paymentDetails; 2196 } 2197 2198 // Add coupon 2199 if (this.hasCoupon()) { 2200 cart['coupon'] = this.coupon.getCode(); 2201 } 2202 if (fields === 'items') { 2203 return cart.items; 2204 } else { 2205 return cart; 2206 } 2207 } 2208 2209 /** 2210 * @since 1.5.0 2211 * 2212 * @param {String} fields Optional. all|order|items. 'all' by default. 2213 * @return {String} Snapshot MD5 hash. 2214 */ 2215 getHash(fields = 'all') { 2216 if (fields !== 'order') { 2217 return md5(JSON.stringify(this.toArray(fields))); 2218 } else { 2219 return md5(JSON.stringify(this.getOrder())); 2220 } 2221 } 2222 2223 /** 2224 * @since 1.5.0 2225 * 2226 * @param {String} hash Last saved snapshot. 2227 * @param {String} fields Optional. all|order|items. 'all' by default. 2228 * @return {Boolean} 2229 */ 2230 didChange(hash, fields = 'all') { 2231 return hash !== this.getHash(fields); 2232 } 2233 2234 /** 2235 * @since 1.4.0 (Replaced the method <code>addCustomerDetails()</code>) 2236 * 2237 * @param {Object} customerDetails 2238 */ 2239 setCustomerDetails(customerDetails) { 2240 jQuery.extend(this.customerDetails, customerDetails); 2241 } 2242 2243 /** 2244 * @since 1.5.0 2245 * 2246 * @param {Object} paymentDetails 2247 */ 2248 setPaymentDetails(paymentDetails) { 2249 jQuery.extend(this.paymentDetails, paymentDetails); 2250 } 2251 2252 /** 2253 * @since 1.6.2 2254 */ 2255 reset() { 2256 this.setupProperties(); 2257 } 2258 2259 /** 2260 * @since 1.9.0 2261 * 2262 * @return {Date} 2263 */ 2264 getMinDate() { 2265 let minDate = null;
2266 this.items.forEach(cartItem => { 2267 if (!cartItem.date) { 2268 return; 2269 } 2270 if (!minDate || minDate > cartItem.date) { 2271 minDate = new Date(cartItem.date.getTime()); 2272 } 2273 }); 2274 return minDate || mpa_today(); 2275 } 2276 2277 /** 2278 * @since 1.9.0 2279 * 2280 * @return {Number[]} 2281 */ 2282 getServiceIds() { 2283 let serviceIds = this.items.map(cartItem => { 2284 return cartItem.service != null ? cartItem.service.id : 0; 2285 }); 2286 serviceIds = mpa_array_unique(serviceIds); 2287 return serviceIds; 2288 } 2289 2290 /** 2291 * @since 1.9.0 2292 * 2293 * @param {Service[]} services 2294 */ 2295 updateServices(services) { 2296 for (let service of services) { 2297 this.items.forEach(cartItem => { 2298 if (cartItem.service != null && cartItem.service.id === service.id) { 2299 cartItem.service = service; 2300 } 2301 }); 2302 } 2303 } 2304 2305 /** 2306 * @since 1.11.0 2307 * 2308 * @param {Coupon} coupon 2309 */ 2310 setCoupon(coupon) { 2311 this.coupon = coupon; 2312 } 2313 2314 /** 2315 * @since 1.11.0 2316 */ 2317 removeCoupon() { 2318 this.coupon = null; 2319 } 2320 2321 /** 2322 * @since 1.11.0 2323 */ 2324 hasCoupon() { 2325 return this.coupon != null; 2326 } 2327 2328 /** 2329 * @since 1.11.0 2330 */ 2331 testCoupon() { 2332 if (this.hasCoupon() && !this.coupon.isApplicableForCart(this)) { 2333 this.removeCoupon(); 2334 } 2335 } 2336 } 2337 2338 /** 2339 * @abstract 2340 * 2341 * @since 1.0 2342 */ 2343 class AbstractEntity { 2344 /** 2345 * @param {Number} id 2346 * @param {Object} properties Optional. 2347 * 2348 * @since 1.0 2349 */ 2350 constructor(id, properties = {}) { 2351 this.id = id; 2352 this.setupProperties(); 2353 this.setupValues(properties); 2354 } 2355 2356 /** 2357 * @access protected 2358 * 2359 * @since 1.0 2360 */ 2361 setupProperties() {} 2362 2363 /** 2364 * @param {Object} properties 2365 * 2366 * @access protected 2367 * 2368 * @since 1.0 2369 */ 2370 setupValues(properties) { 2371 for (let property in properties) { 2372 this[property] = properties[property]; 2373 } 2374 } 2375 2376 /** 2377 * @return {Number} 2378 * 2379 * @since 1.11.0 2380 */ 2381 getId() { 2382 return this.id; 2383 } 2384 } 2385 2386 /** 2387 * Draft version just to present employee as an object in some code. 2388 * 2389 * @since 1.4.0 2390 */ 2391 class Employee extends AbstractEntity { 2392 /** 2393 * @since 1.4.0 2394 * @access protected 2395 */ 2396 setupProperties() { 2397 super.setupProperties(); 2398 this.name = ''; 2399 } 2400 } 2401 2402 /** 2403 * Draft version just to present location as an object in some code. 2404 * 2405 * @since 1.4.0 2406 */ 2407 class Location extends AbstractEntity { 2408 /** 2409 * @since 1.4.0 2410 * @access protected 2411 */ 2412 setupProperties() { 2413 super.setupProperties(); 2414 this.name = ''; 2415 } 2416 } 2417 2418 /** 2419 * @since 1.0 2420 */ 2421 class Service extends AbstractEntity { 2422 /** 2423 * @since 1.0 2424 * @access protected 2425 */ 2426 setupProperties() { 2427 // Declare only the required minimum for methods. Still get all fields 2428 // from REST requests 2429 super.setupProperties(); 2430 this.name = ''; 2431 this.price = 0; 2432 2433 /** 2434 * @since 1.14.0 2435 * string disabled|fixed|percentage 2436 */ 2437 this.depositType = 'disabled'; 2438 /** 2439 * @since 1.14.0 2440 */ 2441 this.depositAmount = 0; 2442 this.duration = 0; // Minutes 2443 2444 this.bufferTimeBefore = 0; // Minutes 2445 this.bufferTimeAfter = 0; // Minutes 2446 this.timeBeforeBooking = 0; // Minutes 2447 this.maxAdvanceTimeBeforeReservation = 0; // Minutes 2448 2449 this.minCapacity = 1; 2450 this.maxCapacity = 1; 2451 this.multiplyPrice = false; 2452 this.variations = {}; // {Employee ID: {employee, price, duration, min_capacity, max_capacity}} 2453 2454 /** @since 1.4.0 */ 2455 this.image = ''; 2456 2457 /** @since 1.4.0 */ 2458 this.thumbnail = ''; 2459 } 2460 2461 /** 2462 * @since 1.0 2463 * @since 1.3.1 added the <code>capacity</code> argument. 2464 * 2465 * @param {Number} employeeId Optional. 0 by default. 2466 * @param {Number} capacity Optional. 0 by default (minimum capacity of the 2467 * service). 2468 * @return {Number} 2469 */ 2470 getPrice(employeeId = 0, capacity = 0) { 2471 if (!capacity) { 2472 capacity = this.minCapacity; 2473 } 2474 let price = this.getVariation('price', employeeId, this.price); 2475 if (this.multiplyPrice) { 2476 price *= capacity; 2477 } 2478 return price; 2479 } 2480 2481 /** 2482 * @since 1.0 2483 * 2484 * @param {Number} employeeId Optional. 2485 * @return {Number} 2486 */ 2487 getDuration(employeeId = 0) { 2488 return this.getVariation('duration', employeeId, this.duration); 2489 } 2490 2491 /** 2492 * @since 1.3.1 2493 * 2494 * @param {Number} employeeId Optional. 2495 * @return {Number} 2496 */ 2497 getMinCapacity(employeeId = 0) { 2498 return this.getVariation('min_capacity', employeeId, this.minCapacity); 2499 } 2500 2501 /** 2502 * @since 1.3.1 2503 * 2504 * @param {Number} employeeId Optional. 2505 * @return {Number} 2506 */ 2507 getMaxCapacity(employeeId = 0) { 2508 return this.getVariation('max_capacity', employeeId, this.maxCapacity); 2509 } 2510 2511 /** 2512 * @since 1.9.0 2513 * 2514 * @param {Number} employeeId Optional. 2515 * @return {Number[]} 2516 */ 2517 getCapacityRange(employeeId = 0) { 2518 let minCapacity = this.getMinCapacity(employeeId); 2519 let maxCapacity = this.getMaxCapacity(employeeId); 2520 return mpa_array_range(minCapacity, maxCapacity); 2521 } 2522 2523 /** 2524 * @since 1.3.1 2525 * 2526 * @param {Number} employeeId Optional. 2527 * @return {Number} The maximum number of guests in range [0; ∞). 2528 * 2529 * @todo Not used. Remove after implementing the Events feature. 2530 */ 2531 getMaxGuests(employeeId = 0) { 2532 let maxCapacity = this.getMaxCapacity(employeeId); 2533 let minCapacity = this.getMinCapacity(employeeId); 2534 let maxGuests = maxCapacity - minCapacity; 2535 return Math.max(0, maxGuests); 2536 } 2537 2538 /** 2539 * @since 1.3.1 2540 * @access protected 2541 * 2542 * @param {String} field 2543 * @param {Number} employeeId 2544 * @param {*} defaultValue 2545 * @return {*} 2546 */ 2547 getVariation(field, employeeId, defaultValue) { 2548 if (employeeId in this.variations) { 2549 return this.variations[employeeId][field]; 2550 } else { 2551 return defaultValue; 2552 } 2553 } 2554 } 2555
2556 /** 2557 * @since 1.4.0 2558 */ 2559 class EntityUtils { 2560 /** 2561 * @since 1.4.0 2562 * 2563 * @param {AbstractEntity} entity 2564 * @param {AbstractRepository} repository 2565 * @param {Boolean} forceReload Optional. False by default. 2566 * @return {Promise} 2567 */ 2568 static loadInBackground(entity, repository, forceReload = false) { 2569 return repository.findById(entity.id, forceReload).then(loadedEntity => { 2570 // If got real result (not null)... 2571 if (loadedEntity !== null) { 2572 // ... then copy all property values to existing entity 2573 for (let property in loadedEntity) { 2574 entity[property] = loadedEntity[property]; 2575 } 2576 } 2577 return loadedEntity; 2578 }); 2579 } 2580 } 2581 2582 /** 2583 * @since 1.11.0 2584 */ 2585 class Coupon extends AbstractEntity { 2586 setupProperties() { 2587 super.setupProperties(); 2588 this.status = 'new'; 2589 this.code = ''; 2590 this.description = ''; 2591 this.type = 'fixed'; 2592 this.amount = 0; 2593 this.expirationDate = null; 2594 this.serviceIds = []; 2595 this.minDate = null; 2596 this.maxDate = null; 2597 this.usageLimit = 0; 2598 this.usageCount = 0; 2599 } 2600 2601 /** 2602 * @access protected 2603 * 2604 * @param {Object} properties 2605 */ 2606 setupValues(properties) { 2607 // Parse dates 2608 for (let dateProperty of ['expirationDate', 'minDate', 'maxDate']) { 2609 let dateValue = properties[dateProperty]; 2610 if (dateValue != null && dateValue !== '') { 2611 this[dateProperty] = mpa_parse_date(dateValue); 2612 } 2613 delete properties[dateProperty]; 2614 } 2615 2616 // Set up other properties 2617 super.setupValues(properties); 2618 } 2619 2620 /** 2621 * @return {String} 2622 */ 2623 getCode() { 2624 return this.code; 2625 } 2626 2627 /** 2628 * @param {Cart} cart 2629 * @return {Boolean} 2630 */ 2631 isApplicableForCart(cart) { 2632 let isApplicable = false; 2633 cart.items.forEach(cartItem => { 2634 if (this.isApplicableForCartItem(cartItem)) { 2635 isApplicable = true; 2636 return false; // Stop the cycle 2637 } 2638 }); 2639 2640 return isApplicable; 2641 } 2642 2643 /** 2644 * @param {CartItem} cartItem 2645 * @return {Boolean} 2646 */ 2647 isApplicableForCartItem(cartItem) { 2648 if (!cartItem.isSet()) { 2649 return false; 2650 } 2651 if (this.serviceIds.length > 0 && this.serviceIds.indexOf(cartItem.service.id) == -1) { 2652 return false; 2653 } 2654 if (this.minDate != null && cartItem.date < this.minDate) { 2655 return false; 2656 } 2657 if (this.maxDate != null && cartItem.date > this.maxDate) { 2658 return false; 2659 } 2660 2661 // Applicable 2662 return true; 2663 } 2664 2665 /** 2666 * @param {Cart} cart 2667 * @return {Number} 2668 */ 2669 calcDiscountAmount(cart) { 2670 let discountPrice = this.calcDiscountForCart(cart); 2671 return Math.min(discountPrice, cart.getSubtotalPrice()); 2672 } 2673 2674 /** 2675 * @access protected 2676 * 2677 * @param {Cart} cart 2678 * @return {Number} 2679 */ 2680 calcDiscountForCart(cart) { 2681 let discount = 0; 2682 cart.items.forEach(cartItem => { 2683 discount += this.calcDiscountForCartItem(cartItem); 2684 }); 2685 return discount; 2686 } 2687 2688 /** 2689 * @access protected 2690 * 2691 * @param {CartItem} cartItem 2692 * @return {Number} 2693 */ 2694 calcDiscountForCartItem(cartItem) { 2695 let discountPrice = 0; 2696 if (this.isApplicableForCartItem(cartItem)) { 2697 let itemPrice = cartItem.getPrice(); 2698 switch (this.type) { 2699 case 'fixed': 2700 discountPrice = this.amount; 2701 break; 2702 case 'percentage': 2703 discountPrice = itemPrice * this.amount / 100; 2704 break; 2705 } 2706 2707 // Don't exceed with amount > 100% 2708 discountPrice = Math.min(discountPrice, itemPrice); 2709 } 2710 return discountPrice; 2711 } 2712 } 2713 2714 /** 2715 * @param {Number} number 2716 * @param {Number} decimals 2717 * @param {String} decimalSeparator 2718 * @param {String} thousandsSeparator 2719 * @return {String} 2720 * 2721 * @since 1.0 2722 */ 2723 function mpa_number_format(number, decimals = 0, decimalSeparator = '.', thousandsSeparator = ',') { 2724 // + Original by: Jonas Raoni Soares Silva (http://www.jsfromhell.com) 2725 // + Improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) 2726 // + Bugfix by: Michael White (http://crestidg.com) 2727 let sign = '', 2728 i, 2729 j, 2730 kw, 2731 kd, 2732 km; 2733 if (number < 0) { 2734 sign = '-'; 2735 number *= -1; 2736 } 2737 i = parseInt(number = (+number || 0).toFixed(decimals)) + ''; 2738 if ((j = i.length) > 3) { 2739 j = j % 3; 2740 } else { 2741 j = 0; 2742 } 2743 km = j ? i.substr(0, j) + thousandsSeparator : ''; 2744 kw = i.substr(j).replace(/(\d{3})(?=\d)/g, '$1' + thousandsSeparator); 2745 kd = decimals ? decimalSeparator + Math.abs(number - i).toFixed(decimals).replace(/-/, 0).slice(2) : ''; 2746 return sign + km + kw + kd; 2747 } 2748 2749 /** 2750 * @param {String} string 2751 * @return {String} 2752 * 2753 * @since 1.0 2754 */ 2755 function mpa_unprefix(string) { 2756 if (string.indexOf('mpa_') === 0) { 2757 return string.substring(4); // Remove the public prefix 2758 } else if (string.indexOf('_mpa_') === 0) { 2759 return string.substring(5); // Remove the private prefix 2760 } else { 2761 return string; // Already without the prefix 2762 } 2763 } 2764
2765 /** 2766 * Will trim '5.00' to '5', but leave '5.50' as is. 2767 * 2768 * @param {String} price 2769 * @param {String} decimalSeparator Optional. Decimal separator from settings by 2770 * default. 2771 * @return {String} 2772 * 2773 * @since 1.0 2774 */ 2775 function mpa_trim_price(price, decimalSeparator = null) { 2776 if (decimalSeparator == null) { 2777 decimalSeparator = mpapp().settings().getDecimalSeparator(); 2778 } 2779 let regex = new RegExp('\\' + decimalSeparator + '0+$'); // /\.0+$/ 2780 price = price.replace(regex, ''); 2781 return price; 2782 } 2783 2784 /** 2785 * @param {*} value 2786 * @return {Boolean} 2787 * 2788 * @since 1.2 2789 */ 2790 function mpa_boolval(value) { 2791 return !!value; 2792 } 2793 2794 /** 2795 * @param {*} value 2796 * @return {Number} 2797 * 2798 * @since 1.2 2799 */ 2800 function mpa_intval(value) { 2801 let intValue = parseInt(value); 2802 if (!isNaN(intValue)) { 2803 return intValue; 2804 } else { 2805 return value << 0; 2806 } 2807 } 2808 2809 /** 2810 * @since 1.3.1 2811 * 2812 * @param {Number} number 2813 * @param {Number} min 2814 * @param {Number} max 2815 * @return {Number} The number in range [min; max]. 2816 */ 2817 function mpa_limit(number, min, max) { 2818 return Math.max(min, Math.min(number, max)); 2819 } 2820 2821 /** 2822 * @abstract 2823 * 2824 * @since 1.0 2825 */ 2826 class AbstractRepository { 2827 /** 2828 * @since 1.0 2829 */ 2830 constructor(postType) { 2831 this.postType = postType; 2832 this.entityType = mpa_unprefix(postType); 2833 this.savedEntities = {}; // {Entity ID: Entity instance} 2834 } 2835 2836 /** 2837 * @param {Number} id 2838 * @param {Boolean} forceReload Optional. False by default. 2839 * @return {Promise} 2840 * 2841 * @since 1.0 2842 */ 2843 findById(id, forceReload = false) { 2844 if (!id) { 2845 return Promise.resolve(null); 2846 } else if (!forceReload && this.haveEntity(id) && this.getEntity(id) != null) { 2847 return Promise.resolve(this.getEntity(id)); 2848 } 2849 return this.requestEntity(id).then(entityData => { 2850 let entity = this.mapRestDataToEntity(entityData); 2851 2852 // Save the most actual result 2853 this.saveEntity(id, entity); 2854 return entity; 2855 }, error => { 2856 // Rewrite the last result, even if entity == null 2857 this.saveEntity(id, null); 2858 return null; 2859 }); 2860 } 2861 2862 /** 2863 * @param {Number[]} ids 2864 * @param {Boolean} forceReload 2865 * @return {AbstractEntity[]) 2866 */ 2867 findAll(ids, forceReload = false) { 2868 let loadIds = []; 2869 let entities = []; 2870 for (let id of ids) { 2871 if (this.haveEntity(id) && !forceReload) { 2872 entities.push(this.getEntity(id)); 2873 } else { 2874 loadIds.push(id); 2875 } 2876 } 2877 if (loadIds.length === 0) { 2878 return Promise.resolve(entities); 2879 } 2880 return this.requestEntities(loadIds).then(rawEntities => { 2881 for (let rawEntity of rawEntities) { 2882 let entity = this.mapRestDataToEntity(rawEntity); 2883 this.saveEntity(entity.id, entity); 2884 entities.push(entity); 2885 } 2886 return entities; 2887 }, error => []); 2888 } 2889 2890 /** 2891 * @param {Number} id 2892 * @return {Promise} 2893 * 2894 * @access protected 2895 * 2896 * @since 1.0 2897 */ 2898 requestEntity(id) { 2899 return mpa_rest_get(this.getRoute(), { 2900 id 2901 }); 2902 } 2903 2904 /** 2905 * @access protected 2906 * 2907 * @param {Number[]} ids 2908 * @return {Promise} 2909 */ 2910 requestEntities(ids) { 2911 return mpa_rest_get(this.getRoute(), { 2912 id: ids 2913 }); 2914 } 2915 2916 /** 2917 * @access protected 2918 * 2919 * @param {Number} id 2920 * @return {Boolean} 2921 */ 2922 haveEntity(id) { 2923 return id in this.savedEntities; 2924 } 2925 2926 /** 2927 * @access protected 2928 * 2929 * @param {Number} id 2930 * @return {AbstractEntity|null} 2931 */ 2932 getEntity(id) { 2933 return this.savedEntities[id] || null; 2934 } 2935 2936 /** 2937 * @param {Number} id 2938 * @param {AbstractEntity|Null} entity 2939 * 2940 * @access protected 2941 * 2942 * @since 1.0 2943 */ 2944 saveEntity(id, entity) { 2945 this.savedEntities[id] = entity; 2946 } 2947 2948 /** 2949 * @abstract 2950 * 2951 * @param {Object} entityData 2952 * @return {AbstractEntity} 2953 * 2954 * @access protected 2955 * 2956 * @since 1.0 2957 */ 2958 mapRestDataToEntity(entityData) { 2959 return null; 2960 } 2961 2962 /** 2963 * @return {String} 2964 * 2965 * @since 1.0 2966 */ 2967 getRoute() { 2968 return `/${this.entityType}s`; // '/services' 2969 } 2970 } 2971 2972 /** 2973 * @since 1.11.0 2974 */ 2975 class CouponRepository extends AbstractRepository { 2976 /** 2977 * @return {Promise} Coupon or null|Error. 2978 */ 2979 findByCode(couponCode, suppressErrors = false) { 2980 return mpa_rest_get(this.getRoute(), { 2981 code: couponCode 2982 }).then(couponData => {
2983 let coupon = this.mapRestDataToEntity(couponData); 2984 2985 // Save the most actual result 2986 this.saveEntity(coupon.getId(), coupon); 2987 return coupon; 2988 }, error => { 2989 if (!suppressErrors) { 2990 throw error; 2991 } else { 2992 return null; 2993 } 2994 }); 2995 } 2996 2997 /** 2998 * @param {Object} entityData 2999 * @return {Coupon} 3000 * 3001 * @access protected 3002 */ 3003 mapRestDataToEntity(entityData) { 3004 return new Coupon(entityData.id, entityData); 3005 } 3006 } 3007 3008 /** 3009 * @param {Date|String} time 3010 * @param {String} format Optional. 'public', 'internal' ('H:i') or custom time 3011 * format. 'public' by default. 3012 * @return {String} 3013 * 3014 * @since 1.0 3015 */ 3016 function mpa_format_time(time, format = 'public') { 3017 if (format == 'internal') { 3018 return mpa_format_date(time, 'H:i'); 3019 } else if (format == 'public') { 3020 return mpa_format_date(time, mpa_time_format()); 3021 } else { 3022 return mpa_format_date(time, format); 3023 } 3024 } 3025 3026 /** 3027 * @param {String} timeString Only the internal format is acceptable - 'H:i' 3028 * (the function will not check if the format is OK). 3029 * @return {Date} 3030 * 3031 * @since 1.0 3032 */ 3033 function mpa_parse_time(timeString) {
3034 let time = timeString.split(':'); 3035 let hours = parseInt(time[0]); 3036 let minutes = parseInt(time[1]); 3037 let date = mpa_today(); 3038 date.setHours(hours, minutes); 3039 return date; 3040 } 3041 3042 /** 3043 * Public time format, set in Settings > General. 3044 * 3045 * @return {String} 3046 * 3047 * @since 1.0 3048 */ 3049 function mpa_time_format() { 3050 return mpapp().settings().getTimeFormat(); 3051 } 3052 3053 /** 3054 * @param {Number} serviceId 3055 * @param {Date} fromDate 3056 * @param {Date} toDate 3057 * @param {Object} args Optional. 3058 * @param {Number[]} args['employees'] Optional. Limited list of employee IDs. 3059 * @param {Number[]} args['locations'] Optional. Limited list of location IDs. 3060 * @param {String} args['exclude'] Optional. Items to exclude in the format of the cart items. 3061 * @return {Promise} <code>{'Y-m-d' => {Time period string => Array of [Employee ID, Location ID]}}</code> 3062 * 3063 * @since 1.2.1 3064 */ 3065 function mpa_time_slots(serviceId, fromDate, toDate, args) { 3066 // Build new args object for REST API 3067 let restArgs = { 3068 service_id: serviceId, 3069 employees: args['employees'] ? args['employees'].join(',') : '', 3070 locations: args['locations'] ? args['locations'].join(',') : '', 3071 from_date: mpa_format_date(fromDate, 'internal'), 3072 to_date: mpa_format_date(toDate, 'internal'), 3073 format: 'full', 3074 exclude: args['exclude'] ? args['exclude'] : [], 3075 since_today: 'since_today' in args ? args['since_today'] : true 3076 }; 3077 3078 // Request time slots 3079 return mpa_rest_get('/calendar/time?nocache', restArgs).catch(error => console.error('Failed to make time slots in mpa_time_slots().', error.message) || {}); 3080 } 3081 3082 /** 3083 * @since 1.0 3084 */ 3085 class TimePeriod { 3086 /** 3087 * @param {Date|String} timeOrPeriod 3088 * @param {Date|String|Null} endTime 3089 * 3090 * @since 1.0 3091 */ 3092 constructor(timeOrPeriod, endTime = null) { 3093 this.setupProperties(); 3094 if (endTime == null) { 3095 this.parsePeriod(timeOrPeriod); 3096 } else { 3097 this.setStartTime(timeOrPeriod); 3098 this.setEndTime(endTime); 3099 } 3100 } 3101 3102 /** 3103 * @access protected 3104 * 3105 * @since 1.0 3106 */ 3107 setupProperties() { 3108 this.startTime = null; // Date 3109 this.endTime = null; // Date 3110 } 3111 3112 /** 3113 * @param {String} period 3114 * 3115 * @since 1.0 3116 */ 3117 parsePeriod(period) { 3118 // Explode '08:00 - 14:00' into ['08:00', '14:00'] 3119 let time = period.split(' - '); 3120 this.setStartTime(time[0]); 3121 this.setEndTime(time[1]); 3122 } 3123 3124 /** 3125 * @param {String|Number} startTime 3126 * 3127 * @since 1.0 3128 */ 3129 setStartTime(startTime) { 3130 this.startTime = this.convertToTime(startTime); 3131 } 3132 3133 /** 3134 * @param {String|Number} endTime 3135 * 3136 * @since 1.0 3137 */ 3138 setEndTime(endTime) { 3139 this.endTime = this.convertToTime(endTime); 3140 } 3141 3142 /** 3143 * @param {Date|String} input 3144 * @return {Date} 3145 * 3146 * @access protected 3147 * 3148 * @since 1.0 3149 */ 3150 convertToTime(input) { 3151 if (typeof input == 'string') { 3152 return mpa_parse_time(input); 3153 } else { 3154 return new Date(input); // Clone 3155 } 3156 } 3157 3158 /** 3159 * @param {Date} date 3160 * 3161 * @since 1.0 3162 */ 3163 setDate(date) { 3164 this.startTime.setFullYear(date.getFullYear()); 3165 this.startTime.setMonth(date.getMonth(), date.getDate()); 3166 this.endTime.setFullYear(date.getFullYear()); 3167 this.endTime.setMonth(date.getMonth(), date.getDate()); 3168 } 3169 3170 /** 3171 * @param {TimePeriod} period 3172 * @return {Boolean} 3173 * 3174 * @since 1.0 3175 */ 3176 intersectsWith(period) { 3177 return this.startTime < period.endTime && this.endTime > period.startTime; 3178 } 3179 3180 /** 3181 * @param {TimePeriod} period 3182 * @return {Boolean} 3183 * 3184 * @since 1.0 3185 */ 3186 isSubperiodOf(period) { 3187 return this.startTime >= period.startTime && this.endTime <= period.endTime; 3188 } 3189 3190 /** 3191 * @param {TimePeriod} period 3192 * 3193 * @since 1.0 3194 */ 3195 mergePeriod(period) { 3196 this.startTime.setTime(Math.min(this.startTime.getTime(), period.startTime.getTime())); 3197 this.endTime.setTime(Math.max(this.endTime.getTime(), period.endTime.getTime())); 3198 } 3199 3200 /** 3201 * @param {TimePeriod} period 3202 * 3203 * @since 1.0 3204 */ 3205 diffPeriod(period) { 3206 if (this.startTime < period.startTime) { 3207 this.endTime.setTime(Math.min(period.startTime.getTime(), this.endTime.getTime())); 3208 } else { 3209 this.startTime.setTime(Math.max(period.endTime.getTime(), this.startTime.getTime())); 3210 } 3211 } 3212 3213 /** 3214 * @param {TimePeriod} period Must be subperiod of the current object. 3215 * @return {TimePeriod[]} 3216 * 3217 * @since 1.0 3218 */ 3219 splitByPeriod(period) { 3220 let split = []; 3221 if (period.startTime.getTime() - this.startTime.getTime() > 0) {
3222 split.push(new TimePeriod(this.startTime, period.startTime)); 3223 } 3224 if (this.endTime.getTime() - period.endTime.getTime() > 0) { 3225 split.push(new TimePeriod(period.endTime, this.endTime)); 3226 } 3227 return split; 3228 } 3229 3230 /** 3231 * @return {Boolean} 3232 * 3233 * @since 1.0 3234 */ 3235 isEmpty() { 3236 return this.endTime.getTime() - this.startTime.getTime() <= 0; 3237 } 3238 3239 /** 3240 * @param {String} format Optional. 'public', 'short', 'internal' or custom 3241 * time format. 'public' by default. 3242 * @param {String} glue Optional. ' - ' by default. 3243 * @return {String} 3244 * 3245 * @since 1.0 3246 */ 3247 toString(format = 'public', glue = ' - ') { 3248 // Force glue ' - ' for internal values 3249 if (format == 'internal') { 3250 glue = ' - '; 3251 } 3252 3253 // mpa_format_time() does not support format 'short' 3254 let timeFormat = format == 'short' ? 'public' : format; 3255 let startTime = mpa_format_time(this.startTime, timeFormat); 3256 let endTime = mpa_format_time(this.endTime, timeFormat); 3257 if (format == 'short' && startTime == endTime) { 3258 return startTime; 3259 } else { 3260 return startTime + glue + endTime; 3261 } 3262 } 3263 } 3264 3265 /** 3266 * @since 1.0 3267 */ 3268 class Reservation extends AbstractEntity { 3269 /** 3270 * @access protected 3271 * 3272 * @since 1.0 3273 */ 3274 setupProperties() { 3275 // Declare only the required minimum for methods. Still get all fields 3276 // from REST requests 3277 super.setupProperties(); 3278 this.serviceId = 0; 3279 this.date = null; // Date 3280 3281 this.serviceTime = null; // TimePeriod 3282 this.bufferTime = null; // TimePeriod 3283 } 3284 3285 /** 3286 * @param {Object} properties 3287 * 3288 * @access protected 3289 * 3290 * @since 1.0 3291 */ 3292 setupValues(properties) { 3293 for (let property in properties) { 3294 if (property == 'date') { 3295 this.setDate(properties[property]); 3296 } else if (property == 'serviceTime') { 3297 this.setServiceTime(properties[property]); 3298 } else if (property == 'bufferTime') { 3299 this.setBufferTime(properties[property]); 3300 } else { 3301 this[property] = properties[property]; 3302 } 3303 } 3304 } 3305 3306 /** 3307 * @param {Date|String} date 3308 * 3309 * @since 1.0 3310 */ 3311 setDate(date) { 3312 if (typeof date == 'string') { 3313 this.date = mpa_parse_date(date); 3314 } else { 3315 this.date = date; 3316 } 3317 if (this.serviceTime != null) { 3318 this.serviceTime.setDate(this.date); 3319 } 3320 if (this.bufferTime != null) { 3321 this.bufferTime.setDate(this.date); 3322 } 3323 } 3324 3325 /** 3326 * @param {TimePeriod|String} serviceTime 3327 * 3328 * @since 1.0 3329 */ 3330 setServiceTime(serviceTime) { 3331 if (typeof serviceTime == 'string') { 3332 this.serviceTime = new TimePeriod(serviceTime); 3333 } else { 3334 this.serviceTime = serviceTime; 3335 } 3336 if (this.date != null) { 3337 this.serviceTime.setDate(this.date); 3338 } 3339 } 3340 3341 /** 3342 * @param {TimePeriod|String} bufferTime 3343 * 3344 * @since 1.0 3345 */ 3346 setBufferTime(bufferTime) { 3347 if (typeof bufferTime == 'string') { 3348 this.bufferTime = new TimePeriod(bufferTime); 3349 } else { 3350 this.bufferTime = bufferTime; 3351 } 3352 if (this.date != null) { 3353 this.bufferTime.setDate(this.date); 3354 } 3355 } 3356 } 3357 3358 /** 3359 * @since 1.0 3360 */ 3361 class ReservationRepository extends AbstractRepository { 3362 /** 3363 * @param {Number} serviceId 3364 * @param {Object} args Optional. 3365 * @param {Date|String} args['from_date'] 3366 * @param {Date|String} args['to_date'] 3367 * @return {Promise} 3368 * 3369 * @since 1.0 3370 */ 3371 findAllByService(serviceId, args = {}) { 3372 let restArgs = { 3373 service_id: serviceId 3374 }; 3375 3376 // Add date range 3377 if (args.from_date != undefined) { 3378 restArgs['from_date'] = mpa_format_date(args.from_date, 'internal'); 3379 } 3380 if (args.to_date != undefined) { 3381 restArgs['to_date'] = mpa_format_date(args.to_date, 'internal'); 3382 } 3383 3384 // Request reservations 3385 let findPromise = mpa_rest_get('/bookings/reservations?nocache', restArgs) 3386 3387 // Save entities 3388 .then(reservations => { 3389 let entities = []; 3390 for (let reservation of reservations) { 3391 let entity = this.mapRestDataToEntity(reservation); 3392 3393 // Save entities 3394 this.saveEntity(entity.id, entity); 3395 entities.push(entity); 3396 } 3397 return entities; 3398 }) 3399 3400 // Log error 3401 .catch(error => {
3402 console.error('No reservations found.', error.message); 3403 return []; // Always return array 3404 }); 3405 3406 return findPromise; 3407 } 3408 3409 /** 3410 * @param {Object} entityData 3411 * @return {Reservation} 3412 * 3413 * @access protected 3414 * 3415 * @since 1.0 3416 */ 3417 mapRestDataToEntity(entityData) { 3418 return new Reservation(entityData.id, entityData); 3419 } 3420 } 3421 3422 /** 3423 * @since 1.0 3424 */ 3425 class DatePeriod { 3426 /** 3427 * @param {Date|String} dateOrPeriod 3428 * @param {Date|String|Null} endDate 3429 * 3430 * @since 1.0 3431 */ 3432 constructor(dateOrPeriod, endDate = null) { 3433 this.setupProperties(); 3434 if (endDate == null) { 3435 this.parsePeriod(dateOrPeriod); 3436 } else { 3437 this.setStartDate(dateOrPeriod); 3438 this.setEndDate(endDate); 3439 } 3440 } 3441 3442 /** 3443 * @access protected 3444 * 3445 * @since 1.0 3446 */ 3447 setupProperties() { 3448 this.startDate = null; // Date 3449 this.endDate = null; // Date 3450 } 3451 3452 /** 3453 * @param {String} period 3454 * 3455 * @access protected 3456 * 3457 * @since 1.0 3458 */ 3459 parsePeriod(period) { 3460 // Explode '2020-01-25 - 2020-02-10' into ['2020-01-25', '2020-02-10'] 3461 let dates = period.split(' - '); 3462 this.setStartDate(dates[0]); 3463 this.setEndDate(dates[1]); 3464 } 3465 3466 /** 3467 * @param {String|Date} startDate 3468 * 3469 * @since 1.0 3470 */ 3471 setStartDate(startDate) { 3472 this.startDate = this.convertToDate(startDate); 3473 } 3474 3475 /** 3476 * @param {String|Date} endDate 3477 * 3478 * @since 1.0 3479 */ 3480 setEndDate(endDate) { 3481 this.endDate = this.convertToDate(endDate); 3482 } 3483 3484 /** 3485 * @param {Date|String} input 3486 * @return {Date} 3487 * 3488 * @access protected 3489 * 3490 * @since 1.0 3491 */ 3492 convertToDate(input) { 3493 if (typeof input == 'string') { 3494 return mpa_parse_date(input) || mpa_today(); 3495 } else { 3496 return new Date(input); // Clone 3497 } 3498 } 3499 3500 /** 3501 * @return {Number} 3502 * 3503 * @since 1.0 3504 */ 3505 calcDays() { 3506 let diff = this.endDate.getTime() - this.startDate.getTime(); 3507 3508 // Convert milliseconds to days 3509 let days = Math.round(diff / 1000 / 3600 / 24); // Remember: both dates have time 00:00:00.000, 3510 // so the math function is not so important 3511 3512 return days; 3513 } 3514 3515 /** 3516 * @param {Date|String} date 3517 * @return {Boolean} 3518 * 3519 * @since 1.0 3520 */ 3521 inPeriod(date) { 3522 if (typeof date == 'string') { 3523 date = mpa_parse_date(date); 3524 } 3525 return date != null && date >= this.startDate && date <= this.endDate; 3526 } 3527 3528 /** 3529 * @return {Object} {"Y-m-d" date string: Date} 3530 * 3531 * @since 1.0 3532 */ 3533 splitToDates() { 3534 let dates = {}; 3535 for (let date = new Date(this.startDate); date <= this.endDate; date.setDate(date.getDate() + 1)) { 3536 let dateString = mpa_format_date(date, 'internal'); 3537 let dateClone = new Date(date); 3538 dates[dateString] = dateClone; 3539 } 3540 return dates; 3541 } 3542 3543 /** 3544 * @return {String} 3545 * 3546 * @since 1.0 3547 */ 3548 toString() { 3549 return mpa_format_date(this.startDate, 'internal') + ' - ' + mpa_format_date(this.endDate, 'internal'); 3550 } 3551 } 3552 3553 /** 3554 * @since 1.0 3555 */ 3556 class Schedule extends AbstractEntity { 3557 /** 3558 * @access protected 3559 * 3560 * @since 1.0 3561 */ 3562 setupProperties() { 3563 // Declare only the required minimum for methods. Still get all fields 3564 // from REST requests 3565 super.setupProperties(); 3566 this.timetable = []; // [Day index: array of {time_period, location, activity}] 3567 this.workTimetable = []; // [Day index: array of {time_period, location}] 3568 this.customWorkdays = []; // Array of {date_period, time_period} 3569 this.daysOff = {}; // {"Y-m-d" date string: Date} 3570 } 3571 3572 /** 3573 * @param {Object} properties 3574 * 3575 * @access protected 3576 * 3577 * @since 1.0 3578 */ 3579 setupValues(properties) { 3580 for (let property in properties) { 3581 if (property == 'timetable') { 3582 this.setTimetable(properties[property]); 3583 } else if (property == 'customWorkdays') { 3584 this.setCustomWorkdays(properties[property]); 3585 } else if (property == 'daysOff') { 3586 this.setDaysOff(properties[property]); 3587 } else { 3588 this[property] = properties[property]; 3589 } 3590 } 3591 } 3592 3593 /** 3594 * @param {Array} timetable [Day index: array of {time_period, location, activity}]. 3595 * 3596 * @since 1.0 3597 */ 3598 setTimetable(timetable) { 3599 this.timetable = []; 3600 this.workTimetable = []; 3601 3602 // For each day of a week 3603 timetable.forEach(periods => { 3604 let dayPeriods = []; 3605 let workPeriods = []; 3606 3607 // Add periods to current day 3608 periods.forEach(period => { 3609 let timePeriod = new TimePeriod(period['time_period']); 3610 3611 // Add period to general list 3612 dayPeriods.push({ 3613 time_period: timePeriod, 3614 location: period['location'], 3615 activity: period['activity'] 3616 }); 3617 3618 // Add period to working list 3619 if (period['activity'] == 'work') { 3620 workPeriods.push({ 3621 time_period: timePeriod, 3622 location: period['location'] 3623 }); 3624 } 3625 }); 3626 3627 // Save day periods 3628 this.timetable.push(dayPeriods); 3629 this.workTimetable.push(workPeriods); 3630 }); 3631 } 3632 3633 /** 3634 * @param {Array} customWorkdays Array of {date_period, time_period}. 3635 * 3636 * @since 1.0 3637 */ 3638 setCustomWorkdays(customWorkdays) { 3639 this.customWorkdays = []; 3640 for (let period of customWorkdays) { 3641 this.customWorkdays.push({ 3642 date_period: new DatePeriod(period['date_period']), 3643 time_period: new TimePeriod(period['time_period']) 3644 }); 3645 } 3646 } 3647 3648 /** 3649 * @param {String[]} daysOff 3650 * 3651 * @since 1.0 3652 */ 3653 setDaysOff(daysOff) { 3654 this.daysOff = {}; 3655 3656 // For each date period 3657 for (let dayOff of daysOff) { 3658 let datePeriod = new DatePeriod(dayOff); 3659 let datesInPeriod = datePeriod.splitToDates(); 3660 jQuery.extend(this.daysOff, datesInPeriod); 3661 } 3662 } 3663 3664 /** 3665 * @param {Date|String} date 3666 * @return {Boolean} 3667 * 3668 * @since 1.0 3669 */ 3670 isDayOff(date) { 3671 if (typeof date != 'string') { 3672 date = mpa_format_date(date, 'internal'); 3673 } 3674 return date in this.daysOff; 3675 } 3676 3677 /** 3678 * @param {Date|String} date 3679 * @param {Number} locationId Optional. 3680 * @return {TimePeriod[]} 3681 * 3682 * @since 1.0 3683 */ 3684 getWorkingHours(date, locationId = 0) { 3685 // Check the days off first 3686 if (this.isDayOff(date)) { 3687 return []; 3688 } 3689 3690 // Convert date string to Date object 3691 if (typeof date == 'string') { 3692 date = mpa_parse_date(date); 3693 } 3694 if (date == null) { 3695 return []; 3696 } 3697 3698 // First, get time periods from the timetable 3699 let timePeriods = []; 3700 let dayOfWeek = date.getDay(); // 0-6 (Sunday-Saturday) 3701 3702 for (let period of this.workTimetable[dayOfWeek]) { 3703 // Filter by location ID 3704 if (locationId == 0 || period['location'] == locationId) { 3705 timePeriods.push(period['time_period']); 3706 } 3707 } 3708 3709 // Second, add custom workdays no matter the location 3710 for (let period of this.customWorkdays) { 3711 if (period['date_period'].inPeriod(date)) { 3712 timePeriods.push(period['time_period']); 3713 } 3714 } 3715 return timePeriods; 3716 } 3717 } 3718
3719 /** 3720 * @since 1.0 3721 */ 3722 class ScheduleRepository extends AbstractRepository { 3723 /** 3724 * @param {Object} entityData 3725 * @return {Schedule} 3726 * 3727 * @access protected 3728 * 3729 * @since 1.0 3730 */ 3731 mapRestDataToEntity(entityData) { 3732 return new Schedule(entityData.id, entityData); 3733 } 3734 } 3735 3736 /** 3737 * @since 1.0 3738 */ 3739 class ServiceRepository extends AbstractRepository { 3740 /** 3741 * @param {Object} entityData 3742 * @return {Service} 3743 * 3744 * @access protected 3745 * 3746 * @since 1.0 3747 */ 3748 mapRestDataToEntity(entityData) { 3749 return new Service(entityData.id, entityData); 3750 } 3751 } 3752 3753 /** 3754 * @since 1.0 3755 */ 3756 class RepositoriesContainer { 3757 /** 3758 * @since 1.0 3759 */ 3760 constructor() { 3761 this.repositories = {}; 3762 } 3763 3764 /** 3765 * @return {ScheduleRepository} 3766 * 3767 * @since 1.0 3768 */ 3769 schedule() { 3770 if (this.repositories['schedule'] == undefined) { 3771 this.repositories['schedule'] = new ScheduleRepository('mpa_schedule'); 3772 } 3773 return this.repositories['schedule']; 3774 } 3775 3776 /** 3777 * @return {ServiceRepository} 3778 * 3779 * @since 1.0 3780 */ 3781 service() { 3782 if (this.repositories['service'] == undefined) { 3783 this.repositories['service'] = new ServiceRepository('mpa_service'); 3784 } 3785 return this.repositories['service']; 3786 } 3787 3788 /** 3789 * @return {ReservationRepository} 3790 * 3791 * @since 1.0 3792 */ 3793 reservation() { 3794 if (this.repositories['reservation'] == undefined) { 3795 this.repositories['reservation'] = new ReservationRepository('mpa_reservation'); 3796 } 3797 return this.repositories['reservation']; 3798 } 3799 3800 /** 3801 * @return {CouponRepository} 3802 * 3803 * @since 1.11.0 3804 */ 3805 coupon() { 3806 if (this.repositories['coupon'] == undefined) { 3807 this.repositories['coupon'] = new CouponRepository('mpa_coupon'); 3808 } 3809 return this.repositories['coupon']; 3810 } 3811 3812 /** 3813 * @return {CustomerRepository} 3814 * 3815 * @since 1.18.0 3816 */ 3817 customer() { 3818 if (this.repositories['customer'] === undefined) { 3819 this.repositories['customer'] = new CustomerRepository(); 3820 } 3821 return this.repositories['customer']; 3822 } 3823 3824 /** 3825 * @return {RepositoriesContainer} 3826 * 3827 * @since 1.0 3828 */ 3829 static getInstance() { 3830 if (RepositoriesContainer.instance == undefined) { 3831 RepositoriesContainer.instance = new RepositoriesContainer(); 3832 } 3833 return RepositoriesContainer.instance; 3834 } 3835 } 3836 3837 // Move repository functions to separate file to prevent a dependency cycle that 3838 // leads to an error "Cannot read property 'default' of undefined" on mpapp() 3839 3840 3841 /** 3842 * @return {RepositoriesContainer} 3843 * 3844 * @since 1.0 3845 */ 3846 function mpa_repositories() { 3847 return RepositoriesContainer.getInstance(); 3848 } 3849 3850 let extractedServices = null; // Promise 3851 3852 /** 3853 * @param {Boolean} forceReload Optional. False by default. 3854 * @return {Promise} 3855 * 3856 * @since 1.0 3857 */ 3858 function mpa_extract_available_services(forceReload = false) { 3859 if (forceReload || extractedServices == null) { 3860 // Load available services 3861 extractedServices = mpa_rest_get('/services/available?nocache').catch(error => { 3862 console.error('Unable to extract available services.'); 3863 return {}; 3864 }); 3865 } 3866 return extractedServices; 3867 } 3868 3869 /** 3870 * @since 1.4.0 3871 * 3872 * @param {*} value 3873 * @return {Boolean} 3874 */ 3875 function mpa_filter_default(value) { 3876 return mpa_boolval(value); 3877 } 3878 3879 /** 3880 * @since 1.4.0 (Replaced the class <code>BookingService</code>) 3881 */ 3882 class AvailabilityService { 3883 /** 3884 * @since 1.4.0 3885 * @access protected 3886 */ 3887 setupProperties() { 3888 /** 3889 * @since 1.4.0 3890 * @var {Object} 3891 * @access protected 3892 * @see PHP function mpa_extract_available_services() to see the structure. 3893 */ 3894 this.availability = {}; 3895 3896 /** 3897 * @since 1.4.0 3898 * @var {Object} {Service ID: Service name} 3899 */ 3900 this.services = {}; 3901 3902 /** 3903 * @since 1.4.0 3904 * @var {Object} {Category slug: Category name} 3905 */ 3906 this.serviceCategories = {}; 3907 3908 /** 3909 * @since 1.4.0 3910 * @var {Object} {Employee ID: Employee name} 3911 */ 3912 this.employees = {}; 3913 3914 /** 3915 * @since 1.4.0 3916 * @var {Object} {Location ID: Location name} 3917 */ 3918 this.locations = {}; 3919 3920 /** 3921 * @since 1.4.0 3922 * @var {Promise} 3923 * @access protected 3924 */ 3925 this.readyPromise = null; 3926 } 3927 3928 /** 3929 * @since 1.4.0 3930 * 3931 * @param {Boolean} forceReload Optional. False by default. 3932 */ 3933 constructor(forceReload = false) { 3934 this.setupProperties(); 3935 this.readyPromise = mpa_extract_available_services(forceReload).then(availability => { 3936 this.setAvailability(availability); 3937 return this; 3938 }); 3939 } 3940 3941 /** 3942 * @since 1.4.0 3943 * @access protected 3944 * 3945 * @param {Object} availability 3946 */ 3947 setAvailability(availability) { 3948 this.availability = availability; 3949 3950 // List available services, categories, employees and locations 3951 for (let serviceId in availability) { 3952 let service = availability[serviceId]; 3953 3954 // List services 3955 this.services[serviceId] = service.name; 3956 3957 // List categories 3958 for (let categorySlug in service.categories) { 3959 let categoryName = service.categories[categorySlug]; 3960 this.serviceCategories[categorySlug] = categoryName; 3961 } 3962 3963 // List employees and locations 3964 for (let employeeId in service.employees) { 3965 let employee = service.employees[employeeId]; 3966 3967 // List employees 3968 this.employees[employeeId] = employee.name; 3969 3970 // List locations 3971 for (let locationId in employee.locations) { 3972 let locationName = employee.locations[locationId]; 3973 this.locations[locationId] = locationName; 3974 } 3975 } 3976 } 3977 } 3978 3979 /** 3980 * @since 1.4.0 3981 * 3982 * @return {Boolean} 3983 */ 3984 isEmpty() { 3985 return mpa_empty(this.availability); 3986 } 3987 3988 /** 3989 * @since 1.4.0 3990 * 3991 * @return {Promise} 3992 */ 3993 ready() { 3994 return this.readyPromise; 3995 } 3996 3997 /** 3998 * @since 1.4.0 3999 * 4000 * @param {Number} serviceId 4001 * @param {Boolean} load Optional. Load properties of the service. True by 4002 * default. 4003 * @return {Service} 4004 */ 4005 getService(serviceId, load = true, callbackAfterLoading = null) { 4006 let service = new Service(serviceId); 4007 4008 // Set known values 4009 if (this.services.hasOwnProperty(serviceId)) { 4010 service.name = this.services[serviceId]; 4011 } 4012 4013 // Load all other properties 4014 if (load === true) {
4015 EntityUtils.loadInBackground(service, mpa_repositories().service()).then(callbackAfterLoading); 4016 } 4017 return service; 4018 } 4019 4020 /** 4021 * @param {Number} serviceId 4022 * @returns {category slug: category name, ...} 4023 */ 4024 getServiceCategories(serviceId) { 4025 return this.availability[serviceId].categories; 4026 } 4027 4028 /** 4029 * @since 1.4.0 4030 * 4031 * @param {Number} employeeId 4032 * @return {Employee} 4033 */ 4034 getEmployee(employeeId) { 4035 let employee = new Employee(employeeId); 4036 4037 // Set known values 4038 if (this.employees.hasOwnProperty(employeeId)) { 4039 employee.name = this.employees[employeeId]; 4040 } 4041 return employee; 4042 } 4043 4044 /** 4045 * @since 1.4.0 4046 * 4047 * @param {Number} locationId 4048 * @return {Location} 4049 */ 4050 getLocation(locationId) { 4051 let location = new Location(locationId); 4052 4053 // Set known values 4054 if (this.locations.hasOwnProperty(locationId)) { 4055 location.name = this.locations[locationId]; 4056 } 4057 return location; 4058 } 4059 4060 /** 4061 * @since 1.4.0 4062 * @since 1.19.0 param locationId 4063 * @since 1.19.0 param employeeId 4064 * 4065 * @param {String} categorySlug Optional. '' by default (all categories). 4066 * @param {Number} locationId Optional. 0 by default (all locations). 4067 * @param {Number} employeeId Optional. 0 by default (all employees). 4068 * @return {Object} {Service ID: Service name} 4069 */ 4070 getAvailableServices(categorySlug = '', locationId = 0, employeeId = 0) { 4071 let services = {}; 4072 4073 // For each service 4074 for (let serviceId in this.availability) { 4075 let service = this.availability[serviceId]; 4076 4077 // Filter by category 4078 if (categorySlug !== '' && !(categorySlug in service.categories)) { 4079 continue; 4080 } 4081 4082 // Filter by location 4083 if (locationId !== 0) { 4084 let found = false; 4085 Object.keys(service.employees).forEach(employeeIndex => { 4086 if (service.employees[employeeIndex].locations.hasOwnProperty(locationId)) { 4087 found = true; 4088 } 4089 }); 4090 if (!found) { 4091 continue; 4092 } 4093 } 4094 4095 // Filter by employee 4096 if (employeeId !== 0 && !(employeeId in service.employees)) { 4097 continue; 4098 } 4099 4100 // Add service 4101 services[serviceId] = service.name; 4102 } 4103 return services; 4104 } 4105 4106 /** 4107 * @since 1.4.0 4108 * @since 1.19.0 param serviceId 4109 * 4110 * @param {Number} serviceId Optional. 0 by default (all categories). 4111 * 4112 * @return {Object} {Category slug: Category name} 4113 */ 4114 getAvailableServiceCategories(serviceId = 0) { 4115 let categories = {}; 4116 4117 // For each service 4118 for (let offerId in this.availability) { 4119 // Filter by service ID 4120 if (serviceId != 0 && offerId != serviceId) { 4121 continue; 4122 } 4123 let service = this.availability[offerId]; 4124 4125 // Merge service categories 4126 jQuery.extend(categories, service.categories); 4127 } 4128 return categories; 4129 } 4130 4131 /** 4132 * @since 1.4.0 4133 * @since 1.19.0 param locationId 4134 * 4135 * @param {Number} serviceId Optional. 0 by default (all services). 4136 * @param {Number} locationId Optional. 0 by default (all locations). 4137 * @return {Object} {Employee ID: Employee name} 4138 */ 4139 getAvailableEmployees(serviceId = 0, locationId = 0) { 4140 let employees = {}; 4141 4142 // For each service 4143 for (let offerId in this.availability) { 4144 // Filter by service ID 4145 if (serviceId != 0 && offerId != serviceId) { 4146 continue; 4147 } 4148 let service = this.availability[offerId]; 4149 4150 // For each employee 4151 for (let employeeId in service.employees) { 4152 let employee = service.employees[employeeId]; 4153 4154 // Filter by locationId 4155 if (locationId !== 0 && !(locationId in employee.locations)) { 4156 continue; 4157 } 4158 4159 // Add employee 4160 employees[employeeId] = employee.name; 4161 } 4162 } 4163 return employees; 4164 } 4165 4166 /** 4167 * @since 1.4.0 4168 * 4169 * @param {Number} serviceId Optional. 0 by default (all services). 4170 * @param {Number} employeeId Optional. 0 by default (all employees). 4171 * @return {Object} {Location ID: Location name} 4172 */ 4173 getAvailableLocations(serviceId = 0, employeeId = 0) { 4174 let locations = {}; 4175 4176 // For each service 4177 for (let offerId in this.availability) { 4178 // Filter by service ID 4179 if (serviceId != 0 && offerId != serviceId) { 4180 continue; 4181 } 4182 let service = this.availability[offerId]; 4183 4184 // For each employee 4185 for (let workerId in service.employees) { 4186 // Filter by employee ID 4187 if (employeeId != 0 && workerId != employeeId) { 4188 continue; 4189 } 4190 let employee = service.employees[workerId]; 4191 4192 // Merge locations 4193 jQuery.extend(locations, employee.locations); 4194 } 4195 } 4196 return locations; 4197 } 4198 4199 /** 4200 * @since 1.19.0 4201 * 4202 * @param {string} categorySlug 4203 * @return boolean 4204 */ 4205 isAvailableServiceCategory(categorySlug) { 4206 return this.getAvailableServiceCategories().hasOwnProperty(categorySlug); 4207 } 4208 4209 /** 4210 * @since 1.19.0 4211 * 4212 * @param {Number} serviceId 4213 * @return boolean 4214 */ 4215 isAvailableService(serviceId) { 4216 return this.getAvailableServices().hasOwnProperty(serviceId); 4217 } 4218 4219 /** 4220 * @since 1.19.0 4221 * 4222 * @param {Number} locationId 4223 * @return boolean 4224 */ 4225 isAvailableLocation(locationId) { 4226 return this.getAvailableLocations().hasOwnProperty(locationId); 4227 } 4228 4229 /** 4230 * @since 1.19.0 4231 * 4232 * @param {Number} employeeId 4233 * @return boolean 4234 */ 4235 isAvailableEmployee(employeeId) { 4236 return this.getAvailableEmployees().hasOwnProperty(employeeId); 4237 } 4238 4239 /** 4240 * @since 1.4.0 4241 * 4242 * @param {Number} serviceId 4243 * @param {Number|Number[]} locationId Optional. One or move allowed location. 4244 * @param {String} output Optional. ids|entities. 'ids' by default. 4245 * @return {Array} 4246 */ 4247 filterAvailableEmployees(serviceId, locationId = 0, output = 'ids') { 4248 if (!(serviceId in this.availability)) { 4249 // No such service in available services list 4250 return []; 4251 } 4252 4253 // Init allowed location IDs 4254 let allowedLocations = []; 4255 if (Array.isArray(locationId)) { 4256 allowedLocations = locationId.filter(mpa_filter_default); 4257 } else if (locationId !== 0) { 4258 allowedLocations.push(locationId); 4259 } 4260 4261 // Select all valid employee IDs 4262 let employees = []; 4263 4264 // Filter #1: filter by service ID 4265 for (let employeeId in this.availability[serviceId]['employees']) { 4266 employeeId = mpa_intval(employeeId); 4267 let employee = this.availability[serviceId]['employees'][employeeId]; 4268 4269 // Filter #2: filter by location ID 4270 if (allowedLocations.length === 0) { 4271 // Any employee with any locations list is valid 4272 employees.push(employeeId); 4273 } else { 4274 // Does employee have a valid location? 4275 let employeeLocations = Object.keys(employee.locations).map(mpa_intval);
4276 let allowedForEmployee = mpa_array_intersect(allowedLocations, employeeLocations); 4277 if (allowedForEmployee.length > 0) { 4278 employees.push(employeeId); 4279 } 4280 } 4281 } 4282 4283 // Convert to proper output format 4284 if (employees.length === 0) { 4285 return []; 4286 } else if (output === 'entities') { 4287 return employees.map(employeeId => this.getEmployee(employeeId)); 4288 } else { 4289 return employees; 4290 } 4291 } 4292 4293 /** 4294 * @since 1.4.0 4295 * 4296 * @param {Number} serviceId 4297 * @param {Number|Number[]} employeeId Optional. One or move allowed employee. 4298 * @param {String} output Optional. ids|entities. 'ids' by default. 4299 * @return {Array} 4300 */ 4301 filterAvailableLocations(serviceId, employeeId = 0, output = 'ids') { 4302 if (!(serviceId in this.availability)) { 4303 // No such service in available services list 4304 return []; 4305 } 4306 4307 // Init allowed employee IDs 4308 let allowedEmployees = []; 4309 if (Array.isArray(employeeId)) { 4310 allowedEmployees = employeeId.filter(mpa_filter_default); 4311 } else if (employeeId !== 0) { 4312 allowedEmployees.push(employeeId); 4313 } 4314 4315 // Select all valid location IDs 4316 let locations = []; 4317 4318 // Filter #1: filter by service ID 4319 for (employeeId in this.availability[serviceId]['employees']) { 4320 employeeId = mpa_intval(employeeId); 4321 4322 // Filter #2: filter by employee ID 4323 if (allowedEmployees.length > 0 && allowedEmployees.indexOf(employeeId) === -1) { 4324 continue; 4325 } 4326 let employee = this.availability[serviceId]['employees'][employeeId]; 4327 4328 // Get all available location 4329 for (let locationId in employee.locations) { 4330 locations.push(mpa_intval(locationId)); 4331 } 4332 } 4333 4334 // Filter duplicates 4335 locations = mpa_array_unique(locations); 4336 4337 // Convert to proper output format 4338 if (locations.length === 0) { 4339 return []; 4340 } else if (output === 'entities') { 4341 return locations.map(locationId => this.getLocation(locationId)); 4342 } else { 4343 return locations; 4344 } 4345 } 4346 } 4347 4348 class BookingSteps { 4349 /** 4350 * @param {Cart} cart 4351 */ 4352 constructor(cart) { 4353 this.cart = cart; 4354 this.steps = new Map(); 4355 this.currentStep = null; 4356 this.currentStepId = ''; 4357 } 4358 4359 /** 4360 * @param {AbstractStep} step 4361 * @return {BookingSteps} 4362 */ 4363 addStep(step) { 4364 this.steps.push(step.stepId, step); 4365 return this; 4366 } 4367 4368 /** 4369 * @param {String} stepId 4370 * @return {AbstractStep|null} 4371 */ 4372 getStep(stepId) { 4373 return this.steps.find(stepId); 4374 } 4375 mount($element) { 4376 this.addListeners($element); 4377 } 4378 4379 /** 4380 * @access protected 4381 */ 4382 addListeners($element) { 4383 $element.children('.mpa-booking-step').on('mpa_booking_step_next', (event, details) => this.onStep('next', details)).on('mpa_booking_step_back', (event, details) => this.onStep('back', details)).on('mpa_booking_step_new', (event, details) => this.onStep('new', details)).on('mpa_reset_booking', (event, details) => this.onStep('reset', details)); 4384 } 4385 4386 /** 4387 * @access protected 4388 * 4389 * @param {String} move next|back|new|reset 4390 * @param {Object} eventDetails 4391 * @param {String} eventDetails['step'] Optional. Step ID that initiates the switch. 4392 */ 4393 onStep(move, eventDetails) { 4394 if (eventDetails && eventDetails.step && eventDetails.step !== this.currentStepId) { 4395 return; // Don't accept events from inactive steps 4396 } 4397 4398 // OK, switch step 4399 switch (move) { 4400 case 'next': 4401 this.goToNextStep(); 4402 break; 4403 case 'back': 4404 this.goToPreviousStep(); 4405 break; 4406 case 'new': 4407 this.goToFirstStep(); 4408 break; 4409 case 'reset': 4410 this.reset(); 4411 break; 4412 } 4413 } 4414 4415 /** 4416 * @since 1.19.0 4417 */ 4418 isCurrentStepHiddenGoToNextStep() { 4419 if (this.currentStep === null) { 4420 return; 4421 } 4422 this.currentStep.ready().finally(() => { 4423 if (this.currentStep.isHiddenStep) { 4424 // go to next step with submitting data 4425 this.currentStep.submit(); 4426 } 4427 }); 4428 } 4429 4430 /** 4431 * @since 1.19.0 4432 */ 4433 isCurrentStepHiddenGoToPreviousStep() { 4434 if (this.currentStep === null) { 4435 return; 4436 } 4437 this.currentStep.ready().finally(() => { 4438 if (this.currentStep.isHiddenStep) { 4439 // go to previous step 4440 this.currentStep.cancel(); 4441 } 4442 }); 4443 } 4444 goToNextStep() { 4445 if (this.steps.isEmpty()) { 4446 return; // Nowhere to go 4447 } 4448 4449 let nextStepId = !this.currentStep ? this.steps.firstKey() : this.steps.findNextKey(this.currentStepId); 4450 if (nextStepId === this.currentStepId) { 4451 return; // Nowhere to go, already at the last step 4452 } 4453
4454 this.switchStep(nextStepId); 4455 this.isCurrentStepHiddenGoToNextStep(); 4456 } 4457 goToPreviousStep() { 4458 if (this.steps.isEmpty()) { 4459 return; // Nowhere to go 4460 } 4461 4462 let previousStepId = !this.currentStep ? '' : this.steps.findPreviousKey(this.currentStepId); 4463 if (!previousStepId || previousStepId === this.currentStepId) { 4464 return; // Nowhere to go, already at the beginning 4465 } 4466 4467 this.switchStep(previousStepId); 4468 this.isCurrentStepHiddenGoToPreviousStep(); 4469 } 4470 4471 /** 4472 * Goes to the first step when adding new item to the cart. 4473 */ 4474 goToFirstStep() { 4475 if (this.steps.isEmpty()) { 4476 return; // Nowhere to go 4477 } 4478 4479 // Add new item to the cart 4480 this.cart.createItem(); 4481 4482 // Reset cart item steps 4483 this.steps.forEach(step => { 4484 if (step.getCartContext() === 'cart item') { 4485 step.reset(); 4486 } 4487 }); 4488 4489 // Switch to the first step 4490 let firstStepId = this.steps.firstKey(); 4491 this.switchStep(firstStepId); 4492 this.isCurrentStepHiddenGoToNextStep(); 4493 } 4494 4495 /** 4496 * @param {String} stepId 4497 */ 4498 goToStep(stepId) { 4499 this.switchStep(stepId); 4500 } 4501 4502 /** 4503 * @since 1.19.0 4504 * @return {String} Step Id 4505 */ 4506 getFirstVisibleStepId() { 4507 let firstVisibleStepId = null; 4508 this.steps.forEach(step => { 4509 if (step.isHiddenStep === false) { 4510 firstVisibleStepId = step.stepId; 4511 return false; 4512 } 4513 }); 4514 return firstVisibleStepId; 4515 } 4516 4517 /** 4518 * @since 1.19.0 4519 * @param {String} stepId 4520 * @return {boolean} 4521 */ 4522 isFirstVisibleStepId(stepId) { 4523 return this.getFirstVisibleStepId() === stepId; 4524 } 4525 4526 /** 4527 * @access protected 4528 * 4529 * @param {String} newStepId 4530 */ 4531 switchStep(newStepId) { 4532 let newStep = this.steps.find(newStepId); 4533 if (newStep == null) { 4534 return; // Step not found 4535 } 4536 4537 if (this.isFirstVisibleStepId(newStepId)) { 4538 newStep.hideButtonBack(); 4539 } 4540 4541 // Hide current step 4542 if (this.currentStep != null) { 4543 this.currentStep.hide(); 4544 } 4545 this.currentStep = newStep; 4546 this.currentStepId = newStepId; 4547 4548 // Load and show new step 4549 newStep.load(); 4550 newStep.ready().finally(() => newStep.show()); 4551 } 4552 reset() { 4553 this.cart.reset(); 4554 4555 // goToFirstStep() will also do the cart.createItem() after the reset 4556 this.goToFirstStep(); 4557 4558 // Cart item steps already reseted. Reset all other steps. 4559 this.steps.forEach(step => { 4560 if (step.getCartContext() !== 'cart item') { 4561 step.reset(); 4562 } 4563 }); 4564 } 4565 } 4566 4567 /** 4568 * @abstract 4569 * 4570 * @since 1.0 4571 */ 4572 class AbstractStep { 4573 /** 4574 * @param {Object} $element jQuery element. 4575 * @param {Cart} cart 4576 * 4577 * @since 1.0 4578 */ 4579 constructor($element, cart) { 4580 this.$element = $element; 4581 this.cart = cart; 4582 this.setupProperties(); 4583 this.addListeners(); 4584 } 4585 4586 /** 4587 * @access protected 4588 * 4589 * @since 1.0 4590 */ 4591 setupProperties() { 4592 this.stepId = this.theId(); 4593 this.schema = this.propertiesSchema(); 4594 this.isActive = false; 4595 this.isLoaded = false; 4596 this.isHiddenStep = false; 4597 this.preventReact = false; // See setProperty() 4598 this.preventUpdate = false; // See setProperty() 4599 4600 this.hideButtons = false; // Hide buttons if one of the values is not 4601 // valid. If false, then buttons will be 4602 // disabled, but visible 4603 4604 this.readyPromise = null; 4605 this.$buttons = this.$element.find('.mpa-actions'); 4606 this.$buttonBack = this.$buttons.find('.mpa-button-back'); 4607 this.$buttonNext = this.$buttons.find('.mpa-button-next'); 4608 } 4609 4610 /** 4611 * @abstract 4612 * 4613 * @return {String} 4614 * 4615 * @access protected 4616 * 4617 * @since 1.0 4618 */ 4619 theId() { 4620 return 'abstract'; 4621 } 4622 4623 /** 4624 * @return {String} 'cart'|'cart item' 4625 * 4626 * @since 1.9.0 4627 */ 4628 getCartContext() { 4629 return 'cart'; 4630 } 4631 4632 /** 4633 * @return {Object} {Property name: {type, default}} 4634 * 4635 * @access protected 4636 * 4637 * @since 1.0 4638 */ 4639 propertiesSchema() { 4640 return {}; 4641 } 4642 4643 /** 4644 * @access protected 4645 * 4646 * @since 1.0 4647 */ 4648 addListeners() { 4649 this.$buttonBack.on('click', this.cancel.bind(this)); 4650 this.$buttonNext.on('click', this.submit.bind(this)); 4651 } 4652 4653 /** 4654 * @since 1.0 4655 */ 4656 load() { 4657 if (!this.isLoaded) { 4658 this.readyPromise = this.loadEntities(); 4659 this.isLoaded = true; 4660 } else { 4661 this.readyPromise = this.reload(); 4662 } 4663 } 4664 4665 /** 4666 * @return {Promise} 4667 * @access protected 4668 * 4669 * @since 1.0 4670 */ 4671 loadEntities() { 4672 return Promise.resolve(this); 4673 } 4674 4675 /** 4676 * @return {Promise} 4677 * @access protected 4678 * 4679 * @since 1.0 4680 */ 4681 reload() { 4682 return Promise.resolve(this); 4683 } 4684 4685 /** 4686 * Reset step before a new item. 4687 * 4688 * @since 1.4.0 4689 * @abstract 4690 */ 4691 reset() {} 4692 4693 /** 4694 * @return {Promise} 4695 * 4696 * @since 1.0 4697 */ 4698 ready() { 4699 return this.readyPromise; 4700 } 4701 4702 /** 4703 * @abstract 4704 * 4705 * @return {Boolean} 4706 * 4707 * @since 1.0 4708 */ 4709 isValidInput() { 4710 return false;
4711 } 4712 4713 /** 4714 * @param {String} propertyName 4715 * @param {*} value 4716 * 4717 * @since 1.0 4718 */ 4719 setProperty(propertyName, value) { 4720 if (this.preventUpdate) { 4721 return; 4722 } 4723 let newValue = this.validateProperty(propertyName, value); 4724 4725 // Do nothing if nothing changed 4726 if (newValue === this[propertyName]) { 4727 return; 4728 } 4729 4730 // Is it a change of the dependent property? 4731 let isChain = this.preventReact; 4732 4733 // Block excess reacts and update linked values 4734 this.preventReact = true; 4735 this.updateProperty(propertyName, newValue); 4736 4737 // Run react() after all changes (once) 4738 if (!isChain) { 4739 if (this.isActive) { 4740 this.react(); 4741 } 4742 this.preventReact = false; 4743 } 4744 } 4745 4746 /** 4747 * @param {String} propertyName 4748 * 4749 * @since 1.0 4750 */ 4751 resetProperty(propertyName) { 4752 this.setProperty(propertyName); 4753 } 4754 4755 /** 4756 * @param {String} propertyName 4757 * @param {*} value 4758 * @return {*} 4759 * 4760 * @access protected 4761 * 4762 * @since 1.0 4763 */ 4764 validateProperty(propertyName, value) { 4765 let validValue = value; 4766 if (propertyName in this.schema) { 4767 let propertySchema = this.schema[propertyName]; 4768 if (value == null) { 4769 validValue = propertySchema.default; 4770 } else { 4771 switch (propertySchema.type) { 4772 case 'bool': 4773 validValue = mpa_boolval(value); 4774 break; 4775 case 'integer': 4776 validValue = mpa_intval(value); 4777 break; 4778 } 4779 4780 // Check allowed options (but don't check empty values, like 0 4781 // or "" - they are kind of allowed (to filter dependent fields)) 4782 if (!mpa_empty(validValue) && propertySchema.options != undefined) { 4783 let isAllowed = propertySchema.options.indexOf(validValue) >= 0; 4784 4785 // If new value is not allowed, then don't change the value 4786 if (!isAllowed) { 4787 validValue = this[propertyName]; 4788 } 4789 } 4790 } // If value not null 4791 } else if (value == undefined) { 4792 validValue = null; 4793 } 4794 return validValue; 4795 } 4796 4797 /** 4798 * Executed only when the new value is different from the old one. 4799 * 4800 * @param {String} propertyName 4801 * @param {*} newValue 4802 * 4803 * @access protected 4804 * 4805 * @since 1.0 4806 */ 4807 updateProperty(propertyName, newValue) { 4808 let oldValue = this[propertyName]; 4809 this[propertyName] = newValue; 4810 this.afterUpdate(propertyName, newValue, oldValue); 4811 } 4812 4813 /** 4814 * Executed only when the new value is different from the old one. 4815 * 4816 * @param {String} propertyName 4817 * @param {*} newValue 4818 * @param {*} oldValue 4819 * 4820 * @access protected 4821 * 4822 * @since 1.0 4823 * @since 1.5.0 added the <code>oldValue</code> argument. 4824 */ 4825 afterUpdate(propertyName, newValue, oldValue) {} 4826 4827 /** 4828 * @access protected 4829 * 4830 * @since 1.0 4831 */ 4832 react() { 4833 let inputsValid = this.isValidInput(); 4834 this.$buttonNext.prop('disabled', !inputsValid); 4835 if (this.hideButtons) { 4836 this.$buttons.toggleClass('mpa-hide', !inputsValid); 4837 } 4838 } 4839 4840 /** 4841 * @since 1.0 4842 */ 4843 show() { 4844 // Set active 4845 this.enable(); 4846 4847 // Update elements due to the state 4848 this.react(); 4849 4850 // Show element 4851 this.$element.removeClass('mpa-hide'); 4852 4853 // Hide loading GIF 4854 this.readyPromise.finally(() => this.showReady()); 4855 } 4856 4857 /** 4858 * @access protected 4859 * 4860 * @since 1.0 4861 */ 4862 showReady() { 4863 // Hide loading GIF 4864 this.$element.addClass('mpa-loaded'); 4865 4866 // Show buttons 4867 if (!this.hideButtons) { 4868 this.$buttons.removeClass('mpa-hide'); 4869 } 4870 } 4871 4872 /** 4873 * @since 1.0 4874 */ 4875 hide() { 4876 // Set inactive 4877 this.disable(); 4878 4879 // Hide element 4880 this.$element.addClass('mpa-hide'); 4881 } 4882 4883 /** 4884 * @access protected 4885 * 4886 * @since 1.0 4887 */ 4888 enable() { 4889 this.isActive = true; 4890 this.$buttonBack.prop('disabled', false); 4891 this.$buttonNext.prop('disabled', false); 4892 } 4893 4894 /** 4895 * @access protected 4896 * 4897 * @since 1.0 4898 */ 4899 disable() { 4900 this.isActive = false; 4901 this.$buttonBack.prop('disabled', true); 4902 this.$buttonNext.prop('disabled', true); 4903 } 4904 4905 /** 4906 * @since 1.0 4907 */ 4908 cancel(event) { 4909 if (typeof event !== 'undefined') { 4910 event.stopPropagation(); 4911 } 4912 if (!this.isActive) { 4913 return; 4914 } 4915 4916 // Stop editing 4917 this.disable(); 4918 4919 // Trigger as cancelled 4920 this.triggerBack(); 4921 } 4922 4923 /** 4924 * @since 1.0 4925 */ 4926 submit(event) { 4927 if (typeof event !== 'undefined') { 4928 event.stopPropagation(); 4929 } 4930 if (!this.isActive || !this.isValidInput()) { 4931 return; 4932 } 4933 4934 // Stop editing 4935 this.disable(); 4936 4937 // Submit data 4938 let submitted = this.maybeSubmit(); 4939 if (submitted == undefined) { 4940 // No response - take it as a success 4941 this.triggerNext(); 4942 } else if (typeof submitted !== 'object') { 4943 // If not a Promise 4944 // Succeded or failed 4945 submitted ? this.triggerNext() : this.cancelSubmission(); 4946 } else { 4947 // Wait for a Promise 4948 submitted.then( 4949 // executes if submitted will be resolved 4950 this.triggerNext.bind(this), 4951 // executes if submitted will be rejected 4952 this.cancelSubmission.bind(this)); 4953 } 4954 } 4955 4956 /** 4957 * @since 1.0 4958 * @since 1.5.0 may return <code>Promise</code>. 4959 * 4960 * @access protected 4961 * 4962 * @return {*} 4963 */ 4964 maybeSubmit() {} 4965 4966 /** 4967 * @since 1.5.0 4968 * 4969 * @access protected 4970 */ 4971 cancelSubmission() { 4972 this.enable(); 4973 this.react(); 4974 } 4975 4976 /** 4977 * @access protected 4978 * 4979 * @since 1.0 4980 */ 4981 triggerBack() { 4982 this.$element.trigger('mpa_booking_step_back', { 4983 step: this.stepId 4984 }); 4985 } 4986 4987 /** 4988 * @access protected 4989 * 4990 * @since 1.0 4991 */ 4992 triggerNext() { 4993 this.$element.trigger('mpa_booking_step_next', { 4994 step: this.stepId 4995 }); 4996 } 4997 4998 /** 4999 * @since 1.19.0 5000 */ 5001 hideButtonBack() { 5002 this.$buttonBack.prop('disabled', true); 5003 this.$buttonBack.toggleClass('mpa-hide', true); 5004 } 5005 } 5006
5007 /** 5008 * @since 1.0 5009 */ 5010 class StepBooking extends AbstractStep { 5011 /** 5012 * @access protected 5013 * 5014 * @since 1.0 5015 */ 5016 setupProperties() { 5017 super.setupProperties(); 5018 this.hideButtons = true; 5019 this.isPosted = false; 5020 this.isBooked = false; 5021 this.$message = this.$element.find('.mpa-message').first(); 5022 this.$buttonReset = this.$buttons.find('.mpa-button-reset'); 5023 } 5024 5025 /** 5026 * @return {Promise} 5027 * @access protected 5028 * 5029 * @since 1.0 5030 */ 5031 reload() { 5032 this.isPosted = false; 5033 this.isBooked = false; 5034 this.setMessage(__('Making a reservation...', 'motopress-appointment') + ' <span class="mpa-preloader"></span>'); 5035 return Promise.resolve(this); 5036 } 5037 5038 /** 5039 * @since 1.6.2 5040 * @access protected 5041 */ 5042 addListeners() { 5043 super.addListeners(); 5044 this.$buttonReset.on('click', this.resetForm.bind(this)); 5045 } 5046 5047 /** 5048 * @return {String} 5049 * 5050 * @access protected 5051 * 5052 * @since 1.0 5053 */ 5054 theId() { 5055 return 'booking'; 5056 } 5057 5058 /** 5059 * @since 1.6.2 5060 * @access protected 5061 */ 5062 react() { 5063 if (!this.isPosted) { 5064 return; 5065 } 5066 this.$buttons.removeClass('mpa-hide'); 5067 this.$buttonBack.toggleClass('mpa-hide', this.isBooked); 5068 this.$buttonReset.toggleClass('mpa-hide', !this.isBooked || this.isRedirectNeeded()); 5069 } 5070 5071 /** 5072 * @since 1.0 5073 */ 5074 show() { 5075 super.show(); 5076 5077 // Book services 5078 mpa_rest_post('/bookings', this.cart.toArray()).then(response => { 5079 this.isPosted = this.isBooked = true; 5080 this.setMessage(response.message); 5081 if (this.isRedirectNeeded()) { 5082 this.redirectPayment(); 5083 } else { 5084 this.react(); 5085 } 5086 }, error => { 5087 this.isPosted = true; 5088 this.setMessage(error.message); 5089 this.react(); 5090 }); 5091 } 5092 5093 /** 5094 * @since 1.6.2 5095 * @access protected 5096 */ 5097 showReady() { 5098 super.showReady(); 5099 this.$buttonBack.addClass('mpa-hide'); 5100 this.$buttonReset.addClass('mpa-hide'); 5101 } 5102 5103 /** 5104 * @param {String} message 5105 * 5106 * @since 1.0 5107 */ 5108 setMessage(message) { 5109 this.$message.html(message); 5110 } 5111 5112 /** 5113 * Redirect to complete the payment. 5114 * 5115 * @since 1.6.2 5116 */ 5117 redirectPayment() { 5118 let paymentDetails = this.cart.getPaymentDetails(); 5119 window.location.href = paymentDetails.redirect_url; 5120 } 5121 5122 /** 5123 * @since 1.6.2 5124 * @access protected 5125 */ 5126 isRedirectNeeded() { 5127 let paymentDetails = this.cart.getPaymentDetails(); 5128 return 'redirect_url' in paymentDetails && paymentDetails.redirect_url != ''; 5129 } 5130 5131 /** 5132 * @since 1.6.2 5133 * @access protected 5134 */ 5135 resetForm(event) { 5136 event.preventDefault(); 5137 if (this.isPosted && this.isBooked) { 5138 this.$element.trigger('mpa_reset_booking'); 5139 } 5140 } 5141 } 5142 5143 /** 5144 * @param {Object} atts 5145 * @return {String} 5146 * 5147 * @since 1.0 5148 */ 5149 function mpa_tmpl_atts(atts) { 5150 let output = ''; 5151 for (let name in atts) { 5152 output += ' ' + name + '="' + atts[name] + '"'; 5153 } 5154 return output; 5155 } 5156 5157 /** 5158 * @param {String} label 5159 * @param {Object} atts 5160 * @return {String} 5161 * 5162 * @since 1.0 5163 */ 5164 function mpa_tmpl_button(label, atts = {}) { 5165 atts = jQuery.extend({}, { 5166 type: 'button', 5167 'class': 'button' 5168 }, atts); 5169 return '<button' + mpa_tmpl_atts(atts) + '>' + label + '</button>'; 5170 } 5171 5172 /** 5173 * @since 1.0.0 5174 * @since 1.4.0 builds output only by template. Added the <code>$template</code> argument. 5175 * 5176 * @param {CartItem} cartItem 5177 * @param {Object} $template 5178 * @return {String} 5179 */ 5180 function mpa_tmpl_cart_item(cartItem, $template) { 5181 let selectors = { 5182 // Template tag: jQuery selector 5183 service_id: '.mpa-service-id', 5184 service_name: '.mpa-service-name', 5185 service_thumbnail: '.mpa-service-thumbnail', 5186 employee_id: '.mpa-employee-id', 5187 employee_name: '.mpa-employee-name', 5188 location_id: '.mpa-location-id', 5189 location_name: '.mpa-location-name', 5190 reservation_date: '.mpa-reservation-date', 5191 // Public date string 5192 reservation_save_date: '.mpa-reservation-save-date', 5193 // Internal date string 5194 5195 reservation_time: '.mpa-reservation-time', 5196 // Public start time string 5197 reservation_period: '.mpa-reservation-period', 5198 // Public period time string 5199 reservation_save_period: '.mpa-reservation-save-period', 5200 // Internal period time string 5201 5202 reservation_capacity: '.mpa-reservation-capacity', 5203 // Wrapper for <select> with capacity options 5204 reservation_clients: '.mpa-reservation-clients', 5205 // Only the capacity <option>'s 5206 reservation_clients_count: '.mpa-reservation-clients-count', 5207 // Capacity number: "1", "2" etc. 5208 5209 reservation_price: '.mpa-reservation-price' 5210 }; 5211 5212 // Generate new item 5213 let $cartItem = $template.clone(); 5214 5215 // Set cart ID 5216 $cartItem.attr('data-id', cartItem.getItemId()); 5217 5218 // Replace all tags 5219 for (let tagName in selectors) { 5220 let selector = selectors[tagName]; 5221 let $element = $cartItem.find(selector).first(); 5222 let elementHtml = $element.length > 0 ? $element.html() : ''; 5223 let tag = '{' + tagName + '}'; 5224 if (!elementHtml.includes(tag)) { 5225 continue; 5226 } 5227 let content = ''; 5228 switch (tagName) { 5229 case 'service_id': 5230 content = cartItem.service.id; 5231 break; 5232 case 'service_name': 5233 content = cartItem.service.name; 5234 break; 5235 case 'service_thumbnail': 5236 content = mpa_tmpl_thumbnail_image(cartItem.service.thumbnail); 5237 break; 5238 case 'employee_id': 5239 content = cartItem.employee.id; 5240 break; 5241 case 'employee_name': 5242 content = cartItem.employee.name; 5243 break; 5244 case 'location_id': 5245 content = cartItem.location.id; 5246 break; 5247 case 'location_name': 5248 content = cartItem.location.name; 5249 break; 5250 case 'reservation_date': 5251 content = mpa_format_date(cartItem.date); 5252 break; 5253 case 'reservation_save_date': 5254 content = mpa_format_date(cartItem.date, 'internal'); 5255 break; 5256 case 'reservation_time': 5257 content = mpa_format_time(cartItem.time.startTime); 5258 break; 5259 case 'reservation_period': 5260 content = cartItem.time.toString(); 5261 break; 5262 case 'reservation_save_period': 5263 content = cartItem.time.toString('internal'); 5264 break; 5265 case 'reservation_capacity': 5266 let capacityRange = cartItem.service.getCapacityRange(cartItem.employee.id); 5267 let capacityVariants = mpa_array_combine(capacityRange, capacityRange); 5268 content = mpa_tmpl_select(capacityVariants, cartItem.capacity); 5269 break; 5270 case 'reservation_clients': 5271 let capacityLimits = cartItem.service.getCapacityRange(cartItem.employee.id); 5272 let capacityClients = mpa_array_combine(capacityLimits, capacityLimits); 5273 content = mpa_tmpl_select_options(capacityClients, cartItem.capacity); 5274 break; 5275 case 'reservation_clients_count': 5276 content = cartItem.capacity; 5277 break; 5278 case 'reservation_price': 5279 let employeeId = cartItem.employee.id; 5280 let price = cartItem.service.getPrice(employeeId, cartItem.capacity); 5281 content = mpa_tmpl_price(price); 5282 break; 5283 } 5284 $element.html($element.html().replace(tag, content)); 5285 } // For each tag 5286 5287 // Replace {item_id} with the actual ID 5288 $cartItem.find('[name*="{item_id}"]').each((i, element) => { 5289 element.name = element.name.replace('{item_id}', cartItem.getItemId()); 5290 }); 5291 5292 // hide capacity if we have only one possible client 5293 if (1 === cartItem.service.getCapacityRange().length) { 5294 $cartItem.find('.cell-people').addClass('mpa-hide'); 5295 } 5296 return $cartItem; 5297 } 5298 5299 /** 5300 * @since 1.4.0 5301 * @see Cart.getOrder() 5302 * 5303 * @param {Object} order 5304 * @return {String} 5305 */ 5306 function mpa_tmpl_order(order) { 5307 let output = ''; 5308 output += '<table class="mpa-order widefat">'; 5309 output += '<tbody>'; 5310 for (let product of order.products) { 5311 output += '<tr class="mpa-order-service">'; 5312 output += '<td class="column-service">' + product.name + '</td>'; 5313 output += '<td class="column-price">' + mpa_tmpl_price_number(product.price) + '</td>'; 5314 output += '</tr>'; 5315 } 5316 5317 // Show subtotal price 5318 output += '<tr class="mpa-order-subtotal">'; 5319 output += '<th class="column-subtotal">
5319' + __('Subtotal', 'motopress-appointment') + '</th>'; 5320 output += '<th class="column-price">' + mpa_tmpl_price_number(order.subtotal) + '</th>'; 5321 output += '</tr>'; 5322 output += '</tbody>'; 5323 output += '<tfoot>'; 5324 if (order.coupon) { 5325 output += '<tr class="mpa-order-coupon">'; 5326 output += '<th class="column-coupon">'; 5327 output += 5328 // Translators: %s: Coupon code. 5329 __('Coupon: %s', 'motopress-appointment').replace('%s', order.coupon.code); 5330 output += '</th>'; 5331 output += '<td class="column-price">'; 5332 output += mpa_tmpl_price_number(-order.coupon.amount); 5333 output += ' '; 5334 output += '<a href="#" class="mpa-remove-coupon">' + __('Remove', 'motopress-appointment') + '</a>'; 5335 output += '</td>'; 5336 output += '</tr>'; 5337 } 5338 5339 // Shot total price 5340 output += '<tr class="mpa-order-total">'; 5341 output += '<th class="column-total">' + __('Total', 'motopress-appointment') + '</th>'; 5342 output += '<th class="column-price">' + mpa_tmpl_price_number(order.total) + '</th>'; 5343 output += '</tr>'; 5344 output += '</tfoot>'; 5345 output += '</table>'; 5346 return output; 5347 } 5348 5349 /** 5350 * @since 1.14.0 5351 * @see Cart.getOrder() 5352 * 5353 * @param {Object} order 5354 * @return {String} 5355 */ 5356 function mpa_tmpl_deposit(order) { 5357 const leftToPay = parseFloat(order.total) - parseFloat(order.deposit); 5358 let output = ''; 5359 if (leftToPay > 0) { 5360 output += '<table class="widefat">'; 5361 output += '<tbody>'; 5362 output += '<tr class="mpa-deposit-title">'; 5363 output += '<td class="column-title" colspan="2">'; 5364 output += __('Deposit', 'motopress-appointment'); 5365 output += '</td>'; 5366 output += '</tr>'; 5367 output += '<tr class="mpa-deposit-now">'; 5368 output += '<th class="column-title">'; 5369 output += __('Paying now', 'motopress-appointment'); 5370 output += '</th>'; 5371 output += '<th class="column-price">'; 5372 output += mpa_tmpl_price_number(order.deposit); 5373 output += '</th>'; 5374 output += '</tr>'; 5375 output += '<tr class="mpa-deposit-left">'; 5376 output += '<th class="column-title">'; 5377 output += __('Left to pay', 'motopress-appointment'); 5378 output += '</th>'; 5379 output += '<th class="column-price">'; 5380 output += mpa_tmpl_price_number(leftToPay); 5381 output += '</th>'; 5382 output += '</tr>'; 5383 output += '</tbody>'; 5384 output += '</table>'; 5385 } 5386 return output; 5387 } 5388 5389 /** 5390 * @param {Number} price 5391 * @param {Object} args 5392 * @return {String} 5393 * 5394 * @since 1.0 5395 */ 5396 function mpa_tmpl_price(price, args = {}) { 5397 let settings = mpapp().settings(); 5398 args = jQuery.extend({ 5399 currency_symbol: settings.getCurrencySymbol(), 5400 currency_position: settings.getCurrencyPosition(), 5401 decimal_separator: settings.getDecimalSeparator(), 5402 thousand_separator: settings.getThousandSeparator(), 5403 decimals: settings.getDecimalsCount(), 5404 literal_free: true, 5405 trim_zeros: true 5406 }, args); 5407 let priceString = mpa_number_format(Math.abs(price), args.decimals, args.decimal_separator, args.thousand_separator); 5408 let classes = 'mpa-price'; 5409 if (price == 0) { 5410 classes += ' mpa-zero-price'; 5411 } 5412 if (price == 0 && args.literal_free) { 5413 // Use text 'Free' as a price string 5414 classes += ' mpa-price-free'; 5415 priceString = _x('Free', 'Zero price', 'motopress-appointment'); 5416 } else { 5417 // Trim zeros 5418 if (args.trim_zeros) { 5419 priceString = mpa_trim_price(priceString); 5420 } 5421 5422 // Add currency to the price 5423 let currencySpan = '<span class="mpa-currency">' + args.currency_symbol + '</span>'; 5424 switch (args.currency_position) { 5425 case 'before': 5426 priceString = currencySpan + priceString; 5427 break; 5428 case 'after': 5429 priceString = priceString + currencySpan; 5430 break; 5431 case 'before_with_space': 5432 priceString = currencySpan + ' ' + priceString; 5433 break; 5434 case 'after_with_space': 5435 priceString = priceString + ' ' + currencySpan; 5436 break; 5437 } 5438 5439 // Add sign 5440 if (price < 0) { 5441 priceString = '-' + priceString; 5442 } 5443 } 5444 let priceHtml = '<span class="' + classes + '">' + priceString + '</span>'; 5445 return priceHtml; 5446 } 5447 5448 /** 5449 * @since 1.4.0 5450 * 5451 * @param {Number} price 5452 * @param {Object} args Optional. See mpa_tmpl_price() for details. 5453 * @return {String} 5454 */ 5455 function mpa_tmpl_price_number(price, args = {}) { 5456 // Force number for all results 5457 args.literal_free = false;
5458 return mpa_tmpl_price(price, args); 5459 } 5460 5461 /** 5462 * @param {Object} options 5463 * @param {*} selected 5464 * @param {Object} atts Optional. {} by default. 5465 * @return {String} 5466 * 5467 * @since 1.0 5468 */ 5469 function mpa_tmpl_select(options, selected, atts = {}) { 5470 let output = '<select' + mpa_tmpl_atts(atts) + '>'; 5471 output += mpa_tmpl_select_options(options, selected); 5472 output += '</select>'; 5473 return output; 5474 } 5475 5476 /** 5477 * @param {String|Number} value 5478 * @param {String} label 5479 * @param {Boolean} isSelected 5480 * @return {String} 5481 * 5482 * @since 1.0 5483 */ 5484 function mpa_tmpl_select_option(value, label, isSelected = false) { 5485 let output = ''; 5486 let selectedAttr = isSelected ? ' selected="selected"' : ''; 5487 output = '<option value="' + value + '"' + selectedAttr + '>'; 5488 output += label; 5489 output += '</option>'; 5490 return output; 5491 } 5492 5493 /** 5494 * @param {Object} options 5495 * @param {*} selected 5496 * @return {String} 5497 * 5498 * @since 1.0 5499 */ 5500 function mpa_tmpl_select_options(options, selected) { 5501 let output = ''; 5502 for (let value in options) { 5503 output += mpa_tmpl_select_option(value, options[value], value == selected); 5504 } 5505 return output; 5506 } 5507 5508 /** 5509 * @param {Object} $select jQuery element. 5510 * @param {Object} emptyOptions '— Select —'/'— Any —' value. 5511 * @param {Object} allowedOptions All other values. 5512 * @param {*} selected Selected option value. 5513 * 5514 * @since 1.19.0 5515 */ 5516 function update_select_options($select, emptyOptions, allowedOptions, selected) { 5517 let emptyOptionsHtml = mpa_tmpl_select_options(emptyOptions, selected); 5518 let allowedOptionsHtml = mpa_tmpl_select_options(allowedOptions, selected); 5519 let optionsHtml = emptyOptionsHtml + allowedOptionsHtml; 5520 $select.empty().append(optionsHtml).val(selected); 5521 } 5522 5523 /** 5524 * @since 1.4.0 5525 * 5526 * @param {String} imageUrl 5527 * @return {String} 5528 */ 5529 function mpa_tmpl_thumbnail_image(imageUrl) { 5530 let { 5531 width, 5532 height 5533 } = mpapp().settings().getThumbnailSize(); 5534 let atts = { 5535 width, 5536 height, 5537 src: imageUrl, 5538 'class': 'attachment-thumbnail size-thumbnail' 5539 }; 5540 return '<img' + mpa_tmpl_atts(atts) + '>'; 5541 } 5542 5543 /** 5544 * @since 1.4.0 5545 */ 5546 class StepCart extends AbstractStep { 5547 /** 5548 * @since 1.4.0 5549 * @access protected 5550 */ 5551 setupProperties() { 5552 super.setupProperties(); 5553 this.isBeginCheckoutEventSent = false; 5554 this.$cart = this.$element.find('.mpa-cart'); 5555 this.$items = this.$cart.find('.mpa-cart-items'); 5556 this.$itemTemplate = this.$cart.find('.mpa-cart-item-template'); 5557 this.$noItems = this.$element.find('.no-items'); // May be out of the .mpa-cart 5558 5559 this.$totalPrice = this.$element.find('.mpa-cart-total-price'); 5560 this.$buttonNew = this.$buttons.find('.mpa-button-new'); 5561 } 5562 5563 /** 5564 * @since 1.4.0 5565 * @access protected 5566 * 5567 * @return {String} 5568 */ 5569 theId() { 5570 return 'cart'; 5571 } 5572 5573 /** 5574 * @since 1.4.0 5575 * @access protected 5576 */ 5577 addListeners() { 5578 super.addListeners(); 5579 this.$buttonNew.on('click', this.createNew.bind(this)); 5580 } 5581 5582 /** 5583 * @since 1.4.0 5584 */ 5585 load() { 5586 // Remove template from the cart 5587 this.$itemTemplate.remove(); 5588 this.$itemTemplate.removeClass('mpa-cart-item-template'); 5589 this.updateActiveItemCapacity(); 5590 this.refreshCart(); 5591 this.isLoaded = true; 5592 this.readyPromise = Promise.resolve(this); 5593 } 5594 5595 /** 5596 * @since 1.6.2 5597 */ 5598 reset() { 5599 this.$items.find('.mpa-cart-item').remove(); 5600 this.$noItems.removeClass('mpa-hide'); 5601 this.isBeginCheckoutEventSent = false; 5602 } 5603 5604 /** 5605 * @since 1.4.0 5606 * @access protected 5607 */ 5608 updateActiveItemCapacity() { 5609 let cartItem = this.cart.getActiveItem(); 5610 if (!cartItem) { 5611 return; 5612 } 5613 let service = cartItem.service; 5614 let employeeId = cartItem.employee.id; 5615 let minCapacity = service.getMinCapacity(employeeId); 5616 let maxCapacity = service.getMaxCapacity(employeeId); 5617 5618 // Validate capacity to the limits of the new employee 5619 cartItem.capacity = mpa_limit(cartItem.capacity, minCapacity, maxCapacity); 5620 } 5621 5622 /** 5623 * @since 1.4.0 5624 * @access protected 5625 */ 5626 refreshCart() { 5627 let activeCartItemId = this.cart.getActiveItemId(); 5628 5629 // Add new items
5630 this.cart.items.forEach((cartItem, _, cartItemId) => { 5631 let itemSelector = '.mpa-cart-item[data-id="' + cartItemId + '"]'; 5632 let $cartItem = this.$items.find(itemSelector); 5633 if ($cartItem.length === 0) { 5634 // Add new item 5635 $cartItem = this.addItem(cartItem); 5636 this.bindListeners($cartItem); 5637 } else if (cartItemId === activeCartItemId) { 5638 // Update active item (only) 5639 $cartItem = this.updateItem($cartItem, cartItem); 5640 this.bindListeners($cartItem); 5641 } 5642 }); 5643 5644 // Update total price 5645 this.updateTotalPrice(); 5646 } 5647 5648 /** 5649 * @since 1.4.0 5650 * @access protected 5651 * 5652 * @param {CartItem} cartItem 5653 * @return {Object} New cart item element (jQuery object). 5654 */ 5655 addItem(cartItem) { 5656 let $cartItem = mpa_tmpl_cart_item(cartItem, this.$itemTemplate); 5657 this.$items.append($cartItem); 5658 this.$noItems.addClass('mpa-hide'); 5659 return $cartItem; 5660 } 5661 5662 /** 5663 * @since 1.4.0 5664 * @access protected 5665 * 5666 * @param {Object} $foundItem Existing item (jQuery element). 5667 * @param {CartItem} cartItem 5668 * @return {Object} New cart item element (jQuery object). 5669 */ 5670 updateItem($cartItem, cartItem) { 5671 let $newCartItem = mpa_tmpl_cart_item(cartItem, this.$itemTemplate); 5672 $cartItem.replaceWith($newCartItem); 5673 return $newCartItem; 5674 } 5675 5676 /** 5677 * @since 1.4.0 5678 * 5679 * @param {Object} $item 5680 */ 5681 bindListeners($item) { 5682 let cartItemId = $item.data('id'); 5683 let cartItem = this.cart.getItem(cartItemId); 5684 let $people = $item.find('.mpa-reservation-capacity select, .mpa-reservation-clients select'); 5685 let $price = $item.find('.mpa-reservation-price'); 5686 let $removeButton = $item.find('.mpa-button-remove, .mpa-button-edit-or-remove'); 5687 let $updateButton = $item.find('.mpa-button-edit, .mpa-button-edit-or-remove'); 5688 5689 // Change cart item capacity and prices when amount of people changes 5690 $people.on('change', event => { 5691 let newCapacity = mpa_intval(event.target.value); 5692 let employeeId = cartItem.employee.id; 5693 5694 // Update item capacity 5695 cartItem.capacity = newCapacity; 5696 5697 // Update item price 5698 let itemPrice = cartItem.service.getPrice(employeeId, newCapacity); 5699 $price.html(mpa_tmpl_price(itemPrice)); 5700 5701 // Update total price 5702 this.updateTotalPrice(); 5703 }); 5704 5705 // Add "Remove" listener 5706 if (this.isMultibookingEnabled()) { 5707 $removeButton.on('click', event => { 5708 event.stopPropagation(); 5709 5710 // Remove item element 5711 $item.remove(); 5712 5713 // Remove item from the cart 5714 let removedCartItem = this.cart.getItem(cartItemId); 5715 this.cart.removeItem(cartItemId); 5716 if (this.cart.isEmpty()) { 5717 this.$noItems.removeClass('mpa-hide'); 5718 } 5719 5720 // React to changes 5721 this.updateTotalPrice(); 5722 this.react(); 5723 document.dispatchEvent(new CustomEvent('mpa_remove_from_cart', { 5724 detail: { 5725 cartItem: removedCartItem, 5726 currencyCode: mpapp().settings().getCurrency() 5727 } 5728 })); 5729 }); 5730 } 5731 5732 // Add "Update" listener 5733 if (!this.isMultibookingEnabled()) { 5734 $updateButton.on('click', () => { 5735 this.cart.setActiveItem(cartItemId); 5736 5737 // Go back 5738 this.cancel(); 5739 }); 5740 } 5741 } 5742 5743 /** 5744 * @since 1.4.0 5745 * @access protected 5746 */ 5747 updateTotalPrice() { 5748 this.$totalPrice.html(mpa_tmpl_price_number(this.cart.getTotalPrice())); 5749 } 5750 isMultibookingEnabled() { 5751 return mpapp().settings().isMultibookingEnabled(); 5752 } 5753 5754 /** 5755 * @since 1.4.0 5756 * 5757 * @return {Boolean} 5758 */ 5759 isValidInput() { 5760 return !this.cart.isEmpty(); 5761 } 5762 5763 /** 5764 * @since 1.4.0 5765 * @access protected 5766 */ 5767 createNew() { 5768 if (!this.isActive) { 5769 return; 5770 } 5771 5772 // Stop editing 5773 this.disable(); 5774 5775 // Trigger as cancelled 5776 this.triggerNew(); 5777 } 5778 5779 /** 5780 * @since 1.4.0 5781 * @access protected 5782 */ 5783 triggerNew() { 5784 this.$element.trigger('mpa_booking_step_new', { 5785 step: this.stepId 5786 }); 5787 } 5788 5789 /** 5790 * @access protected 5791 */ 5792 maybeSubmit() { 5793 if (!this.isBeginCheckoutEventSent) { 5794 document.dispatchEvent(new CustomEvent('mpa_begin_checkout', { 5795 detail: { 5796 cart: this.cart, 5797 currencyCode: mpapp().settings().getCurrency() 5798 } 5799 }));
5800 this.isBeginCheckoutEventSent = true; 5801 } 5802 } 5803 } 5804 5805 const KEY_ENTER = 'Enter'; 5806 5807 /** 5808 * @since 1.11.0 5809 */ 5810 class CouponSection { 5811 /** 5812 * @param {jQuery} $element 5813 * @param {Cart} cart 5814 */ 5815 constructor($element, cart) { 5816 this.cart = cart; 5817 this.$element = $element; 5818 this.$couponCode = $element.find('[name="coupon_code"]'); 5819 this.$applyButton = $element.find('.mpa-apply-coupon-button'); 5820 this.$messageHolder = $element.find('.mpa-message-wrapper'); 5821 this.$preloader = $element.find('.mpa-preloader'); 5822 this.$parentForm = $element.parents('.mpa-booking-step').first(); 5823 this.addListeners(); 5824 } 5825 5826 /** 5827 * @access protected 5828 */ 5829 addListeners() { 5830 this.$couponCode.on('keydown', event => { 5831 if (event.code === KEY_ENTER) { 5832 this.onEnter(event); 5833 } 5834 }); 5835 this.$applyButton.on('click', this.onSubmit.bind(this)); 5836 } 5837 5838 /** 5839 * Triggers when user types [Enter] in the input field. 5840 * 5841 * @access protected 5842 * 5843 * @param {Event} event 5844 */ 5845 onEnter(event) { 5846 event.preventDefault(); 5847 event.stopPropagation(); 5848 this.applyCouponCode(event.target.value); 5849 } 5850 5851 /** 5852 * Triggers when user clicks "Apply" button. 5853 * 5854 * @access protected 5855 * 5856 * @param {Event} event 5857 */ 5858 onSubmit(event) { 5859 event.preventDefault(); 5860 event.stopPropagation(); 5861 this.applyCouponCode(this.$couponCode.val()); 5862 } 5863 5864 /** 5865 * @param {String} couponCode 5866 */ 5867 applyCouponCode(couponCode) { 5868 this.clearMessage(); 5869 if (!couponCode) { 5870 this.setMessage(__('Coupon code is empty.', 'motopress-appointment')); 5871 return; 5872 } 5873 this.pauseAll(); 5874 mpa_repositories().coupon().findByCode(couponCode).then(coupon => { 5875 if (coupon.isApplicableForCart(this.cart)) { 5876 this.cart.setCoupon(coupon); 5877 this.reset(); 5878 this.triggerApplied(coupon); 5879 this.setMessage(__('Coupon code applied successfully.', 'motopress-appointment')); 5880 } else { 5881 this.setMessage(__('Sorry, your booking is not eligible for this coupon.', 'motopress-appointment')); 5882 } 5883 this.unpauseAll(); 5884 }, error => { 5885 this.setMessage(error.message); 5886 this.unpauseAll(); 5887 }); 5888 } 5889 reset() { 5890 this.$couponCode.val(''); 5891 this.clearMessage(); 5892 this.enable(); 5893 } 5894 disable() { 5895 this.$couponCode.prop('disabled', true); 5896 this.$applyButton.prop('disabled', true); 5897 } 5898 enable() { 5899 this.$couponCode.prop('disabled', false); 5900 this.$applyButton.prop('disabled', false); 5901 } 5902 5903 /** 5904 * @access protected 5905 */ 5906 pauseAll() { 5907 this.disable(); 5908 this.showPreloader(); 5909 this.$parentForm.trigger('mpa_booking_step_disable'); 5910 } 5911 5912 /** 5913 * @access protected 5914 */ 5915 unpauseAll() { 5916 this.enable(); 5917 this.hidePreloader(); 5918 this.$parentForm.trigger('mpa_booking_step_enable'); 5919 } 5920 5921 /** 5922 * @access protected 5923 * 5924 * @param {Coupon} coupon 5925 */ 5926 triggerApplied(coupon) { 5927 this.$parentForm.trigger('mpa_booking_coupon_applied', { 5928 coupon 5929 }); 5930 } 5931 5932 /** 5933 * @param {String} message 5934 */ 5935 setMessage(message) { 5936 this.$messageHolder.html(message).removeClass('mpa-hide'); 5937 } 5938 clearMessage() { 5939 this.$messageHolder.html('').addClass('mpa-hide'); 5940 } 5941 5942 /** 5943 * @access protected 5944 */ 5945 showPreloader() { 5946 this.$preloader.removeClass('mpa-hide'); 5947 } 5948 5949 /** 5950 * @access protected 5951 */ 5952 hidePreloader() { 5953 this.$preloader.addClass('mpa-hide'); 5954 } 5955 } 5956 5957 /** 5958 * @since 1.0 5959 */ 5960 class StepCheckout extends AbstractStep { 5961 /** 5962 * @access protected 5963 * 5964 * @since 1.0 5965 */ 5966 setupProperties() { 5967 super.setupProperties(); 5968 this.name = ''; 5969 this.email = ''; 5970 this.phone = ''; 5971 this.notes = ''; 5972 this.acceptTerms = false; 5973 this.createAccount = false; 5974 this.$checkoutForm = this.$element.find('.mpa-checkout-form'); 5975 this.$name = this.$element.find('.mpa-customer-name'); 5976 this.$email = this.$element.find('.mpa-customer-email');
5977 this.$phone = this.$element.find('.mpa-customer-phone'); 5978 this.$notes = this.$element.find('.mpa-customer-notes'); 5979 this.$order = this.$element.find('.mpa-order'); 5980 if (mpapp().settings().getTermsPageIdForAcceptance()) { 5981 this.$acceptTerms = this.$element.find('.mpa-accept-terms'); 5982 } 5983 this.$messageHolder = this.$element.find('.mpa-message').first(); 5984 this.$preloader = this.$element.find('.mpa-loading'); 5985 if (mpapp().settings().isAllowCustomerAccountCreation()) { 5986 this.$createAccount = this.$element.find('.mpa-customer-create-account'); 5987 this.$createAccountDescription = this.$element.find('.mpa-customer-create-account-description'); 5988 5989 // Set default value of property createAccount. 5990 // Actually for mode: 'Customer account creation' = 'create_automatically' 5991 this.setProperty('createAccount', this.$createAccount.prop('checked')); 5992 } 5993 if (window.MPA_CURRENT_CUSTOMER !== undefined) { 5994 this.setProperty('name', window.MPA_CURRENT_CUSTOMER.name); 5995 this.$name.val(window.MPA_CURRENT_CUSTOMER.name); 5996 this.setProperty('email', window.MPA_CURRENT_CUSTOMER.email); 5997 this.$email.val(window.MPA_CURRENT_CUSTOMER.email); 5998 this.setProperty('phone', window.MPA_CURRENT_CUSTOMER.phone); 5999 let iti = window.intlTelInputGlobals.getInstance(this.$phone[0]); 6000 // set phone number through itt to remove country code from number if it is there 6001 iti.setNumber(window.MPA_CURRENT_CUSTOMER.phone); 6002 // send event to validate phone number 6003 this.$phone.trigger('input'); 6004 } 6005 this.service = null; 6006 this.couponSection = null; 6007 } 6008 6009 /** 6010 * @return {String} 6011 * 6012 * @access protected 6013 * 6014 * @since 1.0 6015 */ 6016 theId() { 6017 return 'checkout'; 6018 } 6019 6020 /** 6021 * @return {Object} {Property name: {type, default}} 6022 * 6023 * @access protected 6024 * 6025 * @since 1.0 6026 */ 6027 propertiesSchema() { 6028 return { 6029 name: { 6030 type: 'string', 6031 default: '' 6032 }, 6033 email: { 6034 type: 'string', 6035 default: '' 6036 }, 6037 phone: { 6038 type: 'string', 6039 default: '' 6040 }, 6041 notes: { 6042 type: 'string', 6043 default: '' 6044 }, 6045 acceptTerms: { 6046 type: 'bool', 6047 default: false 6048 }, 6049 $createAccount: { 6050 type: 'bool', 6051 default: false 6052 } 6053 }; 6054 } 6055 6056 /** 6057 * @access protected 6058 * 6059 * @since 1.0 6060 */ 6061 addListeners() { 6062 super.addListeners(); 6063 this.$checkoutForm.on('submit', event => false); // Disable submit 6064 6065 this.$name.on('input', event => this.setProperty('name', event.target.value)); 6066 this.$email.on('input', event => this.setProperty('email', event.target.value));
6067 this.$phone.on('input', event => { 6068 this.setProperty('phone', ''); 6069 let iti = window.intlTelInputGlobals.getInstance(this.$phone[0]); 6070 if (iti.isValidNumber()) { 6071 this.setProperty('phone', iti.getNumber(intlTelInputUtils.numberFormat.E164)); 6072 } 6073 }); 6074 this.$phone.on("countrychange", event => { 6075 this.setProperty('phone', ''); 6076 let iti = window.intlTelInputGlobals.getInstance(this.$phone[0]); 6077 if (iti.isValidNumber()) { 6078 this.setProperty('phone', iti.getNumber(intlTelInputUtils.numberFormat.E164)); 6079 } 6080 }); 6081 this.$notes.on('input', event => this.setProperty('notes', event.target.value)); 6082 if (mpapp().settings().getTermsPageIdForAcceptance()) { 6083 this.$acceptTerms.on('input', event => this.setProperty('acceptTerms', event.target.checked)); 6084 } 6085 if (mpapp().settings().isAllowCustomerAccountCreation()) { 6086 this.$createAccount.on('input', event => { 6087 this.setProperty('createAccount', event.target.checked); 6088 if (event.target.checked) { 6089 this.$createAccountDescription.removeClass('mpa-hide'); 6090 } else { 6091 this.$createAccountDescription.addClass('mpa-hide'); 6092 } 6093 }); 6094 } 6095 this.$element.on('mpa_booking_step_disable', this.disable.bind(this)); 6096 this.$element.on('mpa_booking_step_enable', this.enable.bind(this)); 6097 this.$element.on('mpa_booking_coupon_applied', () => this.updateOrder()); 6098 } 6099 6100 /** 6101 * @since 1.4.0 6102 */ 6103 load() { 6104 // Initialize coupon section control 6105 if (!this.couponSection) { 6106 if (mpapp().settings().isCouponsEnabled()) { 6107 this.couponSection = new CouponSection(this.$element.find('.mpa-coupon-details'), this.cart); 6108 } 6109 } else { 6110 // Instead of reload() 6111 this.couponSection.reset(); // Clear previous messages 6112 } 6113 6114 // Is the coupon still applicable to the cart? 6115 if (this.cart.hasCoupon()) { 6116 this.cart.testCoupon(); 6117 } 6118 this.updateOrder(); 6119 this.isLoaded = true; 6120 this.readyPromise = Promise.resolve(this); 6121 } 6122 6123 /** 6124 * @since 1.6.2 6125 */ 6126 reset() { 6127 // do not clear name, email and phone for second reservation and nextes 6128 this.$notes.val(''); 6129 // val() will not trigger 'input' event, so reset the properties manually 6130 this.resetProperty('notes'); 6131 if (mpapp().settings().getTermsPageIdForAcceptance()) { 6132 this.$acceptTerms.prop('checked', false); 6133 this.resetProperty('acceptTerms'); 6134 } 6135 if (mpapp().settings().isAllowCustomerAccountCreation()) { 6136 this.clearMessage(); 6137 this.$createAccount.prop('checked', false); 6138 this.resetProperty('createAccount'); 6139 } 6140 6141 // Reset coupon section 6142 if (this.couponSection) { 6143 this.couponSection.reset(); 6144 } 6145 } 6146 6147 /** 6148 * @since 1.4.0 6149 * @access protected 6150 */ 6151 updateOrder() { 6152 if (this.$order.length === 0) { 6153 return; 6154 } 6155 this.$order.empty(); 6156 this.$order.html(mpa_tmpl_order(this.cart.getOrder())); 6157 6158 // Bind remove coupon button 6159 let $removeCoupon = this.$order.find('.mpa-remove-coupon'); 6160 if ($removeCoupon.length > 0) { 6161 $removeCoupon.on('click', this.removeCoupon.bind(this)); 6162 } 6163 } 6164 6165 /** 6166 * @since 1.11.0 6167 * 6168 * @access protected 6169 * 6170 * @param {Event} event 6171 */ 6172 removeCoupon(event) { 6173 event.preventDefault(); 6174 event.stopPropagation(); 6175 this.cart.removeCoupon(); 6176 this.couponSection.clearMessage(); 6177 this.updateOrder(); 6178 } 6179 6180 /** 6181 * @return {Boolean} 6182 * 6183 * @since 1.0 6184 */ 6185 isValidInput() { 6186 return this.isValidName() && this.isValidEmail() && this.isValidPhone() && this.isValidAcceptTerms(); 6187 } 6188 6189 /** 6190 * @since 1.3.1 6191 * @access protected 6192 * 6193 * @return {Boolean} 6194 */ 6195 isValidName() { 6196 return this.name !== ''; 6197 } 6198 6199 /** 6200 * @return {Boolean} 6201 * 6202 * @access protected 6203 * 6204 * @since 1.1.0 6205 */ 6206 isValidEmail() { 6207 return this.email !== '' && !!this.email.match(/.+@.+/); 6208 } 6209 6210 /** 6211 * @return {Boolean} 6212 * 6213 * @access protected 6214 * 6215 * @since 1.1.0 6216 */ 6217 isValidPhone() { 6218 let iti = window.intlTelInputGlobals.getInstance(this.$phone[0]); 6219 return iti.isValidNumber(); 6220 } 6221 6222 /** 6223 * @return {Boolean} 6224 * 6225 * @access protected 6226 * 6227 * @since 1.10.2 6228 */ 6229 isValidAcceptTerms() { 6230 return !mpapp().settings().getTermsPageIdForAcceptance() || mpapp().settings().isPaymentsEnabled() || this.acceptTerms; 6231 } 6232 6233 /** 6234 * @access protected 6235 * 6236 * @since 1.0 6237 */ 6238 react() { 6239 super.react(); 6240 6241 // Always enable submit button to allow validation messages 6242 this.$buttonNext.prop('disabled', false); 6243 } 6244 6245 /** 6246 * @param {String} message 6247 * 6248 * @since 1.18.0 6249 */ 6250 setMessage(message) { 6251 this.$messageHolder.html(message).removeClass('mpa-hide'); 6252 } 6253 6254 /** 6255 * @since 1.18.0 6256 */ 6257 clearMessage() { 6258 this.$messageHolder.html('').addClass('mpa-hide'); 6259 } 6260 6261 /** 6262 * @access protected 6263 * 6264 * @since 1.18.0 6265 */ 6266 showPreloader() { 6267 this.$preloader.removeClass('mpa-hide'); 6268 } 6269 6270 /** 6271 * @access protected 6272 * 6273 * @since 1.18.0 6274 */ 6275 hidePreloader() { 6276 this.$preloader.addClass('mpa-hide'); 6277 } 6278 6279 /** 6280 * @access protected 6281 * 6282 * @since 1.0 6283 */ 6284 maybeSubmit() { 6285 // Disable coupons section 6286 if (this.couponSection) { 6287 this.couponSection.disable(); 6288 } 6289 this.cart.setCustomerDetails({ 6290 name: this.name, 6291 email: this.email, 6292 phone: this.phone, 6293 notes: this.notes, 6294 acceptTerms: this.acceptTerms 6295 }); 6296 if (this.createAccount) { 6297 this.showPreloader(); 6298 const customerData = { 6299 name: this.name, 6300 email: this.email, 6301 phone: this.phone 6302 }; 6303 return mpa_rest_post('/customers/create', customerData).then(customer => { 6304 this.hidePreloader(); 6305 this.clearMessage(); 6306 }, error => { 6307 this.hidePreloader(); 6308 this.setMessage(error); 6309 throw error; 6310 }); 6311 } 6312 } 6313 } 6314
6315 /** 6316 * @since 1.5.0 6317 */ 6318 class PaymentGateway { 6319 /** 6320 * @since 1.5.0 6321 * @access protected 6322 */ 6323 setupProperties() { 6324 /** 6325 * @since 1.5.0 6326 * @var {String} 6327 */ 6328 this.gatewayId = 'basic'; 6329 6330 /** 6331 * @since 1.5.0 6332 * @var {Object} 6333 */ 6334 this.settings = this.getDefaults(); 6335 6336 /** 6337 * @since 1.5.0 6338 * @var {Object} 6339 */ 6340 this.$mountWrapper = null; 6341 6342 /** 6343 * @since 1.5.0 6344 * @var {Promise} 6345 */ 6346 this.loadPromise = null; 6347 6348 /** 6349 * @since 1.5.0 6350 * @var {Boolean} 6351 */ 6352 this.isEnabled = false; 6353 6354 /** 6355 * @since 1.5.0 6356 * @var {Boolean} 6357 */ 6358 this.isMounted = false; 6359 6360 /** 6361 * Input(s) have errors. 6362 * 6363 * @since 1.5.0 6364 * @var {Boolean} 6365 */ 6366 this.haveErrors = false; 6367 } 6368 6369 /** 6370 * @since 1.5.0 6371 * @since 1.6.0 Added cart parameter 6372 * 6373 * @param {Object} $mountWrapper 6374 */ 6375 constructor($mountWrapper, cart) { 6376 this.setupProperties(); 6377 this.$mountWrapper = $mountWrapper; 6378 this.cart = cart; 6379 } 6380 6381 /** 6382 * @since 1.5.0 6383 * 6384 * @return {Promise} 6385 */ 6386 load() { 6387 this.addListeners(); 6388 this.loadPromise = Promise.resolve(this); 6389 return this.loadPromise; 6390 } 6391 6392 /** 6393 * @since 1.5.0 6394 * @access protected 6395 */ 6396 addListeners() {} 6397 6398 /** 6399 * @since 1.5.0 6400 */ 6401 onCartChange(cart) {} 6402 6403 /** 6404 * @since 1.5.0 6405 * @access protected 6406 * 6407 * @param {Object} $mountWrapper 6408 */ 6409 mount($mountWrapper) {} 6410 6411 /** 6412 * @since 1.5.0 6413 * 6414 * @return {Promise} 6415 */ 6416 ready() { 6417 return this.loadPromise; 6418 } 6419 6420 /** 6421 * @since 1.5.0 6422 */ 6423 enable() { 6424 if (!this.isEnabled) { 6425 if (!this.isMounted) { 6426 this.mount(this.$mountWrapper); 6427 this.isMounted = true; 6428 } 6429 this.$mountWrapper.removeClass('mpa-hide'); 6430 this.isEnabled = true; 6431 } 6432 } 6433 6434 /** 6435 * @since 1.5.0 6436 */ 6437 disable() { 6438 if (this.isEnabled) { 6439 this.$mountWrapper.addClass('mpa-hide'); 6440 this.isEnabled = false; 6441 } 6442 } 6443 6444 /** 6445 * @since 1.5.0 6446 * 6447 * @return {Boolean} 6448 */ 6449 isValid() { 6450 return !this.haveErrors; 6451 } 6452 6453 /** 6454 * @since 1.5.0 6455 * 6456 * @param {Cart} cart 6457 * @param {Object} bookingDetails 6458 * @return {Promise} On fulfillment: return processed payment data. On 6459 * rejection: throw error. 6460 */ 6461 processPayment(cart, bookingDetails) { 6462 return mpa_rest_post('/payments/prepare', { 6463 payment_details: cart.paymentDetails 6464 }); 6465 } 6466 6467 /** 6468 * @since 1.5.0 6469 * @access protected 6470 * 6471 * @return {Object} 6472 */ 6473 getDefaults() { 6474 return { 6475 country: mpapp().settings().getCountry(), 6476 redirect_url: { 6477 payment_received: mpapp().settings().getPaymentReceivedPageUrl(), 6478 failed_transaction: mpapp().settings().getFailedTransactionPageUrl() 6479 } 6480 }; 6481 } 6482 6483 /** 6484 * @since 1.6.2 6485 */ 6486 reset() {} 6487 } 6488 6489 /** 6490 * @since 1.14.0 6491 */ 6492 class FreeGateway extends PaymentGateway { 6493 enable() {} 6494 } 6495 6496 /** 6497 * @since 1.5.0 6498 */ 6499 class StripeGatewayView { 6500 /** 6501 * @since 1.5.0 6502 * @access protected 6503 */ 6504 setupProperties() { 6505 /** 6506 * @since 1.14.0 6507 * @var {Methods} 6508 */ 6509 this.methods = null; 6510 6511 /** 6512 * @since 1.5.0 6513 * @var {String} 6514 */ 6515 this.uid = ''; 6516 6517 /** 6518 * @since 1.5.0 6519 * @var {Map} Key - method name ('card', 'ideal'), value - {$nav, $fields}. 6520 */ 6521 this.paymentMethods = new Map(); 6522 6523 /** 6524 * @since 1.5.0 6525 * @var {String} 6526 */ 6527 this.selectedMethod = ''; 6528 6529 /** 6530 * @since 1.5.0 6531 * @var {Object|null} 6532 */ 6533 this.$mountWrapper = null; 6534 6535 /** 6536 * @since 1.5.0 6537 * @var {Object|null} 6538 */ 6539 this.$errorsWrapper = null; 6540 6541 /** 6542 * @since 1.16.0 6543 * @type {Object} 6544 */ 6545 this.$gatewayPreloader = null; 6546 6547 /** 6548 * @since 1.16.0 6549 * @type {Array} 6550 * [ {method_name} => (bool), true - mounted ] 6551 */ 6552 this.mountedMethods = []; 6553 } 6554 6555 /** 6556 * @since 1.5.0 6557 * 6558 * @param {Object[]} Stripe payment methods 6559 */ 6560 constructor(methods) { 6561 this.setupProperties(); 6562 this.methods = methods; 6563 this.uid = mpa_uniqid(); 6564 this.addPaymentMethods(this.methods); 6565 } 6566 mountedMethod() { 6567 let hasUnmountedMethod = false; 6568 Object.entries(this.mountedMethods).forEach((mountedMethod, isMountedMethod) => { 6569 if (!isMountedMethod) { 6570 hasUnmountedMethod = true; 6571 } 6572 }); 6573 if (hasUnmountedMethod) { 6574 this.$gatewayPreloader.addClass('mpa-hide'); 6575 } 6576 } 6577 6578 /** 6579 * @since 1.5.0 6580 * 6581 * @param {String[]} paymentMethods 6582 */ 6583 addPaymentMethods(paymentMethods) { 6584 for (const paymentMethod in paymentMethods) { 6585 if (this.paymentMethods.includesKey(paymentMethod)) { 6586 continue; // Already exists 6587 } 6588 6589 // Add new payment method 6590 this.paymentMethods.push(paymentMethod, { 6591 $nav: null, 6592 $fields: null 6593 }); 6594 6595 // Init this.selectedMethod property 6596 if (!this.selectedMethod) { 6597 this.selectedMethod = paymentMethod; 6598 } 6599 } 6600 } 6601 6602 /** 6603 * @since 1.5.0 6604 * 6605 * @return {Boolean} 6606 */ 6607 isMounted() { 6608 return this.$mountWrapper !== null; 6609 } 6610 6611 /** 6612 * @since 1.5.0 6613 * 6614 * @param {Object} $mountWrapper 6615 */
6616 mount($mountWrapper) { 6617 $mountWrapper.append(this.render()); 6618 this.$gatewayPreloader = $mountWrapper.parent().find('.mpa-payment-gateway-title .mpa-preloader'); 6619 this.$gatewayPreloader.removeClass('mpa-hide'); 6620 6621 // Init all payment methods 6622 this.paymentMethods.forEach((references, i, paymentMethod) => { 6623 references.$nav = $mountWrapper.find('.mpa-stripe-payment-method.' + paymentMethod); 6624 references.$fields = $mountWrapper.find('.mpa-stripe-payment-fields.' + paymentMethod); 6625 const methodControl = this.methods[paymentMethod].getControl(); 6626 if (methodControl !== null) { 6627 const elementSelector = this.getElementSelector(paymentMethod); 6628 this.mountedMethods[paymentMethod] = false; 6629 methodControl.mount(elementSelector); 6630 methodControl.on('ready', paymentMethod => { 6631 this.mountedMethod(paymentMethod); 6632 document.querySelector(elementSelector).classList.remove('mpa-preloader-skeleton-pulsate'); 6633 }); 6634 } 6635 6636 // todo: better to refactor 6637 if (paymentMethod === 'card') { 6638 this.methods['card'].isCanMakePaymentRequest().then(canMakePayment => { 6639 const elementPaymentRequestButtonSelector = this.getElementSelector('payment-request-button'); 6640 const elementPaymentRequestButton = document.querySelector(elementPaymentRequestButtonSelector); 6641 if (!elementPaymentRequestButton) { 6642 return; 6643 } 6644 if (canMakePayment) { 6645 this.mountedMethods['payment_request_button'] = false; 6646 this.methods['card'].paymentRequestButton.mount(elementPaymentRequestButtonSelector); 6647 this.methods['card'].paymentRequestButton.on('ready', event => { 6648 this.mountedMethod('payment_request_button'); 6649 elementPaymentRequestButton.classList.remove('mpa-preloader-skeleton-pulsate'); 6650 }); 6651 } else { 6652 elementPaymentRequestButton.classList.add('mpa-hide'); 6653 document.querySelector('.mpa-stripe-payment-request-button-separator').classList.add('mpa-hide'); 6654 } 6655 }); 6656 } 6657 }); 6658 6659 // Listen change of payment method 6660 $mountWrapper.find('input[name="stripe_payment_method"]').on('change', this.onPaymentMethodChange.bind(this)); 6661 this.$mountWrapper = $mountWrapper; 6662 this.$errorsWrapper = $mountWrapper.find('.mpa-errors'); 6663 } 6664 6665 /** 6666 * @since 1.5.0 6667 * @access protected 6668 * 6669 * @param {Event} event 6670 */ 6671 onPaymentMethodChange(event) { 6672 // Clear previous control 6673 let previousControl = null; 6674 switch (this.selectedMethod) { 6675 case 'card': 6676 case 'ideal': 6677 case 'sepa_debit': 6678 previousControl = this.methods[this.selectedMethod].getControl(); 6679 break; 6680 } 6681 if (previousControl !== null) { 6682 previousControl.clear(); 6683 } 6684 6685 // Select new payment method 6686 this.selectPaymentMethod(event.target.value); 6687 } 6688 6689 /** 6690 * @since 1.5.0 6691 * 6692 * @param {String} paymentMethod 6693 */ 6694 selectPaymentMethod(paymentMethod) { 6695 if (paymentMethod !== this.selectedMethod) { 6696 this.togglePaymentMethod(this.selectedMethod, false); 6697 this.togglePaymentMethod(paymentMethod, true); 6698 this.selectedMethod = paymentMethod; 6699 } 6700 } 6701 6702 /** 6703 * @since 1.5.0 6704 * 6705 * @param {String} paymentMethod 6706 * @param {Boolean} enable 6707 */ 6708 togglePaymentMethod(paymentMethod, enable) { 6709 if (this.isMounted() && this.paymentMethods.includesKey(paymentMethod)) { 6710 let refs = this.paymentMethods.find(paymentMethod); 6711 refs.$nav.toggleClass('active', enable); 6712 refs.$fields.toggleClass('mpa-hide', !enable); 6713 } 6714 } 6715 6716 /** 6717 * @since 1.5.0 6718 * 6719 * @param {String} paymentMethod 6720 * @return {String} 6721 */ 6722 getElementSelector(paymentMethod) { 6723 if (paymentMethod === 'sepa_debit') { 6724 paymentMethod = 'iban'; 6725 } 6726 return '#mpa-stripe-' + paymentMethod + '-element-' + this.uid; 6727 } 6728 6729 /** 6730 * @since 1.5.0 6731 * 6732 * @return {String} 6733 */ 6734 render() { 6735 let output = ''; 6736 output += '<section class="mpa-stripe-payment-container">'; 6737 6738 // Navigation tabs 6739 if (this.paymentMethods.length > 1) { 6740 output += this.renderNavigation(); 6741 } 6742 6743 // Payment fields 6744 for (let paymentMethod of this.paymentMethods.keys) { 6745 output += this.renderFields(paymentMethod); 6746 } 6747 6748 // Errors wrapper 6749 output += '<div class="mpa-errors"></div>'; 6750 output += '</section>'; 6751 return output; 6752 } 6753 6754 /** 6755 * @since 1.5.0 6756 * @access protected 6757 * 6758 * @return {String} 6759 */ 6760 renderNavigation() { 6761 let output = ''; 6762 output += '<nav class="mpa-stripe-payment-methods">'; 6763 output += '<ul>'; 6764 for (let paymentMethod of this.paymentMethods.keys) { 6765 let isSelected = paymentMethod === this.selectedMethod; 6766 let activeClass = isSelected ? ' active' : '';
6767 let checkedAttr = isSelected ? ' checked="checked"' : ''; 6768 output += '<li class="mpa-stripe-payment-method ' + paymentMethod + activeClass + '">'; 6769 output += '<label>'; 6770 output += '<input type="radio" name="stripe_payment_method" value="' + paymentMethod + '"' + checkedAttr + '>'; 6771 output += ' ' + this.methods[paymentMethod].title; 6772 output += '</label>'; 6773 output += '</li>'; 6774 } 6775 output += '</ul>'; 6776 output += '</nav>'; 6777 return output; 6778 } 6779 6780 /** 6781 * @since 1.5.0 6782 * @access protected 6783 * 6784 * @param {String} paymentMethod 6785 * @return {String} 6786 */ 6787 renderFields(paymentMethod) { 6788 let isSelected = paymentMethod === this.selectedMethod; 6789 let hideClass = isSelected ? '' : ' mpa-hide'; 6790 let output = ''; 6791 output += '<div class="mpa-stripe-payment-fields ' + paymentMethod + hideClass + '">'; 6792 output += '<fieldset>'; 6793 switch (paymentMethod) { 6794 case 'card': 6795 output += this.renderCardFields(); 6796 break; 6797 case 'ideal': 6798 output += this.renderIdealFields(); 6799 break; 6800 case 'sepa_debit': 6801 output += this.renderSepaDebitFields(); 6802 break; 6803 default: 6804 output += this.renderRedirectNotice(); 6805 break; 6806 } 6807 output += '</fieldset>'; 6808 if (paymentMethod === 'sepa_debit') { 6809 output += '<p class="notice">'; 6810 output += 6811 // https://stripe.com/docs/sources/sepa-debit#prerequisite 6812 // Translators: %s: Business name. 6813 __('By providing your IBAN and confirming this payment, you authorise (A) %s and Stripe, our payment service provider, to send instructions to your bank to debit your account and (B) your bank to debit your account in accordance with those instructions. You are entitled to a refund from your bank under the terms and conditions of your agreement with your bank. A refund must be claimed within 8 weeks starting from the date on which your account was debited.', 'motopress-appointment').replace('%s', mpapp().settings().getBusinessName()); 6814 output += '</p>'; 6815 } 6816 output += '</div>'; 6817 return output; 6818 } 6819 6820 /** 6821 * @since 1.5.0 6822 * @access protected 6823 * 6824 * @return {String} 6825 */ 6826 renderCardFields() { 6827 let output = ''; 6828 output += '<label for="mpa-stripe-card-element-' + this.uid + '">'; 6829 output += __('Credit or debit card', 'motopress-appointment'); 6830 output += '</label>'; 6831 if (this.methods['card'].isEnabledWallets()) { 6832 output += '<div id="mpa-stripe-payment-request-button-element-' + this.uid + '" class="mpa-stripe-element mpa-stripe-payment-request-button-element mpa-preloader-skeleton-pulsate StripeElement"></div>'; 6833 output += '<div class="mpa-stripe-payment-request-button-separator">' + __('or', 'motopress-appointment') + '</div>'; 6834 } 6835 output += '<div id="mpa-stripe-card-element-' + this.uid + '" class="mpa-stripe-element mpa-stripe-card-element mpa-preloader-skeleton-pulsate"></div>'; 6836 return output; 6837 } 6838 6839 /** 6840 * @since 1.5.0 6841 * @access protected 6842 * 6843 * @return {String} 6844 */ 6845 renderIdealFields() { 6846 let output = ''; 6847 output += '<label for="mpa-stripe-ideal-element-' + this.uid + '">'; 6848 output += __('Select iDEAL Bank', 'motopress-appointment'); 6849 output += '</label>'; 6850 output += '<div id="mpa-stripe-ideal-element-' + this.uid + '" class="mpa-stripe-element mpa-stripe-ideal-element mpa-preloader-skeleton-pulsate"></div>'; 6851 return output; 6852 } 6853 6854 /** 6855 * @since 1.5.0 6856 * @access protected 6857 * 6858 * @return {String} 6859 */ 6860 renderSepaDebitFields() { 6861 let output = ''; 6862 output += '<label for="mpa-stripe-iban-element-' + this.uid + '">'; 6863 output += __('IBAN', 'motopress-appointment'); 6864 output += '</label>'; 6865 output += '<div id="mpa-stripe-iban-element-' + this.uid + '" class="mpa-stripe-element mpa-stripe-iban-element mpa-preloader-skeleton-pulsate"></div>'; 6866 return output; 6867 } 6868 6869 /** 6870 * @since 1.5.0 6871 * @access protected 6872 * 6873 * @return {String} 6874 */ 6875 renderRedirectNotice() { 6876 let output = ''; 6877 output += '<p class="notice">'; 6878 output += __('You will be redirected to a secure page to complete the payment.', 'motopress-appointment'); 6879 output += '</p>'; 6880 return output; 6881 } 6882 6883 /** 6884 * @since 1.5.0 6885 * 6886 * @param {String} message 6887 */ 6888 showError(message) { 6889 if (this.isMounted()) { 6890 this.$errorsWrapper.html(message).removeClass('mpa-hide'); 6891 } 6892 } 6893 6894 /** 6895 * @since 1.5.0 6896 */ 6897 hideErrors() { 6898 if (this.isMounted()) { 6899 this.$errorsWrapper.addClass('mpa-hide').html(''); 6900 } 6901 } 6902 6903 /** 6904 * @since 1.6.2 6905 */ 6906 reset() { 6907 let resetMethod = this.paymentMethods.firstKey(); 6908 this.selectPaymentMethod(resetMethod); 6909 6910 // No need to clear the control, StripeGateway will do the job: 6911 // this.methods.reset(); 6912 } 6913 } 6914
6915 /** 6916 * @since 1.5.0 6917 */ 6918 class CustomizablePaymentGateway extends PaymentGateway { 6919 /** 6920 * @since 1.5.0 6921 * 6922 * @return {Promise} 6923 */ 6924 load() { 6925 // Note: there is no addListeners() here anymore 6926 this.loadPromise = mpa_rest_get('/payments/settings?nocache', { 6927 gateway_id: this.gatewayId 6928 }).catch(error => console.error(error.message) || {}) // "Invalid request: ..." 6929 .then(settings => { 6930 jQuery.extend(this.settings, settings); 6931 return this; 6932 }); 6933 return this.loadPromise; 6934 } 6935 } 6936 6937 /** 6938 * @since 1.14.0 6939 */ 6940 class AbstractMethod { 6941 name = null; 6942 title = null; 6943 control = null; 6944 api = null; 6945 elements = null; 6946 6947 /** 6948 * 6949 * @param {Stripe} api Stripe api ref 6950 * @param {Elements} elements Stripe.elements() 6951 * @param {Elements} elements Stripe.elements() 6952 */ 6953 constructor(api, elements, settings) { 6954 this.api = api; 6955 this.settings = settings; 6956 this.elements = elements; 6957 if (new.target === AbstractMethod) { 6958 throw new Error("Cannot construct Abstract instances directly"); 6959 } 6960 if (this.setupProperties === undefined) {
6961 throw new Error("Must override method: setupProperties()"); 6962 } 6963 this.setupProperties(); 6964 if (this.name === null || this.name === undefined) { 6965 throw new Error('"name" must be defined in a non-abstract payment method class'); 6966 } 6967 if (this.title === null || this.title === undefined) { 6968 throw new Error('"title" must be defined in a non-abstract payment method class'); 6969 } 6970 } 6971 6972 /** 6973 * If payment method has control elements, this method must creates an instance of the Payment Element. 6974 * If hasn't controls, need return null. 6975 * 6976 * @see {@link https://stripe.com/docs/js/elements_object/create_payment_element} 6977 * 6978 * @return {Promise<Object>|null} 6979 */ 6980 createControl() { 6981 return null; 6982 } 6983 6984 /** 6985 * If payment method has control elements, this method must creates an instance of the Payment Element. 6986 * If hasn't controls, need return null. 6987 * 6988 * @see {@link https://stripe.com/docs/js/elements_object/create_payment_element} 6989 * 6990 * @return {Promise<Object>|null} 6991 */ 6992 getControl() { 6993 if (!this.control) { 6994 this.control = this.createControl(); 6995 } 6996 return this.control; 6997 } 6998 6999 /** 7000 * @see {@link https://stripe.com/docs/js/element/other_methods/clear} 7001 */ 7002 reset() { 7003 if (this.control !== null) { 7004 this.control.clear(); 7005 } 7006 } 7007 7008 /** 7009 * 7010 * @param {string} name 7011 * @param {string} email 7012 * @param {string} phone 7013 * 7014 * @return {Object} PaymentMethodData 7015 * @see https://stripe.com/docs/api/payment_methods/object 7016 */ 7017 createPaymentMethodData(name, email, phone) { 7018 let paymentMethodData = { 7019 type: this.name, 7020 billing_details: { 7021 name: name.padEnd(3, ' '), 7022 // Some methods require name length 3 or more symbols. Fill requirements by spaces. 7023 email: email, 7024 phone: phone 7025 } 7026 }; 7027 if (this.control !== null) { 7028 paymentMethodData[this.name] = this.control; 7029 } 7030 return paymentMethodData; 7031 } 7032 7033 /** 7034 * 7035 * @param {Object} paymentMethodData 7036 * @see https://stripe.com/docs/api/payment_methods/object 7037 * 7038 * @return {Promise} paymentMethod or error 7039 */ 7040 createPaymentMethod(paymentMethodData) { 7041 return this.api.createPaymentMethod(paymentMethodData); 7042 } 7043 7044 /** 7045 * 7046 * @param client_secret The client secret of the PaymentIntent. 7047 * @param redirect_url_received 7048 * @return {Promise} 7049 */ 7050 confirmPayment(client_secret, redirect_url_received) { 7051 throw new Error('Abstract Method has no implementation'); 7052 } 7053 7054 /** 7055 * @param {Cart} cart 7056 * @param {Object} paymentDetails Gateway settings + booking details. 7057 * @param {Object} processArgs Optional. 7058 * @param {Function} processArgs['error_handler'] Callback to log errors. 7059 * @return {Promise} 7060 */ 7061 processPayment(cart, paymentDetails, processArgs) { 7062 const customer = cart.getCustomer(); 7063 const paymentMethodData = this.createPaymentMethodData(customer.name, customer.email, customer.phone); 7064 7065 // Step 1: create payment method on the client side 7066 return this.createPaymentMethod(paymentMethodData) 7067 7068 // Step 2: create payment intent on the server side 7069 .then(paymentMethod => { 7070 if (paymentMethod.error) { 7071 throw new Error(paymentMethod.error.message); 7072 } 7073 const paymentDetails = jQuery.extend(cart.paymentDetails, { 7074 payment_method_id: paymentMethod.paymentMethod.id 7075 }); 7076 return mpa_rest_post('/payments/prepare', { 7077 payment_details: paymentDetails 7078 }); 7079 }) 7080 7081 // Step 3: confirm payment intent on the client side 7082 .then(paymentIntentClientSecret => { 7083 // todo: param paymentDetails.redirect_url.payment_received is not used in all implementations. 7084 // It is better to move the parameter passing to another place of initialization. 7085 return this.confirmPayment(paymentIntentClientSecret, paymentDetails.redirect_url.payment_received) 7086 7087 // Handle errors 7088 .then(confirmation => { 7089 if (confirmation.error) { 7090 throw new Error(confirmation.error.message); 7091 } else { 7092 return confirmation.paymentIntent; 7093 } 7094 }); 7095 }) 7096 7097 // Step 4: build the final payment data 7098 .then(paymentIntent => { 7099 let paymentData = { 7100 payment_method: this.name,
7101 payment_intent_id: paymentIntent.id 7102 }; 7103 if (paymentIntent.status == 'requires_action' && paymentIntent.next_action.type == 'redirect_to_url') { 7104 paymentData.redirect_url = paymentIntent.next_action.redirect_to_url.url; 7105 } 7106 return paymentData; 7107 }) 7108 7109 // Catch any error 7110 .catch(error => { 7111 console.error('Unable to payment process.', error.message); 7112 if (processArgs.error_handler != undefined) { 7113 processArgs.error_handler(error.message); 7114 } 7115 throw error; 7116 }); 7117 } 7118 } 7119 7120 /** 7121 * snake_case to camelCase 7122 * 7123 * @param {String} 7124 * @return {String} 7125 * 7126 * @since 1.16.0 7127 */ 7128 function mpa_snake_to_camel_case(string) { 7129 return string.toLowerCase().replace(/([-_][a-z])/g, group => group.toUpperCase().replace('-', '').replace('_', '')); 7130 } 7131 7132 /** 7133 * @since 1.14.0 7134 */ 7135 class CardMethod extends AbstractMethod { 7136 setupProperties() { 7137 this.name = 'card'; 7138 this.title = __('Card', 'motopress-appointment'); 7139 7140 /** 7141 * Payment process event object with payment request button 7142 * Necessary for correct processing by the processPayment() method and completion of the payment process 7143 * 7144 * @since 1.16.0 7145 * @protected 7146 * 7147 * @type {Event} 7148 */ 7149 this.paymentRequestButtonEvent = null; 7150 7151 /** 7152 * @since 1.16.0 7153 * @protected 7154 * 7155 * @type {Promise<array|null>} 7156 */ 7157 this.canMakePaymentRequest = Promise.resolve(null); 7158 if (this.isEnabledWallets()) { 7159 this.paymentRequest = this.createPaymentRequest(); 7160 this.canMakePaymentRequest = this.paymentRequest.canMakePayment(); 7161 } 7162 } 7163 7164 /** 7165 * @since 1.16.0 7166 * 7167 * @protected 7168 * 7169 * @return {PaymentRequest} Object of PaymentRequest 7170 */ 7171 createPaymentRequest() { 7172 if (this.paymentRequest) { 7173 return this.paymentRequest; 7174 } 7175 return this.api.paymentRequest({ 7176 country: this.settings.country, 7177 currency: mpapp().settings().getCurrency().toLowerCase(), 7178 total: { 7179 label: __('Total', 'motopress-appointment'), 7180 amount: 0, 7181 pending: true 7182 }, 7183 requestPayerName: false, 7184 requestPayerEmail: false, 7185 requestPayerPhone: false, 7186 requestShipping: false, 7187 disableWallets: this.getDisabledWallets() 7188 }); 7189 } 7190 7191 /** 7192 * @since 1.16.0 7193 * 7194 * @return {Promise<null|array>} 7195 */ 7196 isCanMakePaymentRequest() { 7197 return this.canMakePaymentRequest; 7198 } 7199 7200 /** 7201 * @since 1.16.0 7202 * 7203 * @protected 7204 * 7205 * @return {string[]} 7206 */ 7207 getPossibleWallets() { 7208 return ['apple_pay', 'google_pay', 'link']; 7209 } 7210 7211 /** 7212 * @since 1.16.0 7213 * 7214 * @protected 7215 * 7216 * @return {Boolean} 7217 */ 7218 isEnabledWallets() { 7219 let isEnabledWallets = false; 7220 const wallets = this.getPossibleWallets(); 7221 wallets.forEach(wallet => { 7222 if (this.settings.payment_methods.includes(wallet)) { 7223 isEnabledWallets = true; 7224 } 7225 }); 7226 return isEnabledWallets; 7227 } 7228 7229 /** 7230 * @since 1.16.0 7231 * 7232 * @return {Array} Array of disabled wallets name 7233 */ 7234 getDisabledWallets() { 7235 let disabledWallets = []; 7236 const wallets = this.getPossibleWallets(); 7237 wallets.forEach(wallet => { 7238 if (!this.settings.payment_methods.includes(wallet)) { 7239 const walletNameInCamelCase = mpa_snake_to_camel_case(wallet); 7240 disabledWallets.push(walletNameInCamelCase); 7241 } 7242 }); 7243 return disabledWallets; 7244 } 7245 7246 /** 7247 * @since 1.16.0 7248 * 7249 * @see https://stripe.com/docs/js/elements_object/create_payment_element 7250 * 7251 * @protected 7252 * 7253 * @return {Object} 7254 */ 7255 createPaymentRequestButton() { 7256 return this.elements.create('paymentRequestButton', { 7257 paymentRequest: this.paymentRequest, 7258 style: { 7259 paymentRequestButton: { 7260 height: '50px' 7261 } 7262 } 7263 }); 7264 } 7265 7266 /** 7267 * @since 1.16.0 7268 * @protected 7269 * 7270 * @param {Event} 7271 */ 7272 processPaymentRequestButton(event) { 7273 this.paymentRequestButtonEvent = event; 7274 jQuery('.mpa-booking-step-payment .mpa-actions .mpa-button-next').trigger('click'); 7275 } 7276 7277 /** 7278 * @since 1.16.0 7279 * 7280 * @param {Cart} cart 7281 * @param {Object} processArgs Optional. 7282 * @param {Function} processArgs['error_handler'] Callback to log errors. 7283 * @return {Promise} 7284 */ 7285 proccessPaymentRequestButtonHandler(cart, processArgs) { 7286 const customer = cart.getCustomer(); 7287 return this.api.createPaymentMethod({ 7288 type: 'card', 7289 card: { 7290 token: this.paymentRequestButtonEvent.token.id 7291 }, 7292 billing_details: { 7293 name: customer.name, 7294 email: customer.email, 7295 phone: customer.phone 7296 } 7297 }).then(paymentMethod => { 7298 if (paymentMethod.error) { 7299 this.paymentRequestButtonEvent.complete('fail'); 7300 throw new Error(paymentMethod.error.message); 7301 } 7302 const paymentDetails = jQuery.extend(cart.paymentDetails, { 7303 payment_method_id: paymentMethod.paymentMethod.id 7304 }); 7305 return mpa_rest_post('/payments/prepare', { 7306 payment_details: paymentDetails 7307 }); 7308 }).then(paymentIntentClientSecret => { 7309 // todo: param paymentDetails.redirect_url.payment_received is not used in all implementations. 7310 // It is better to move the parameter passing to another place of initialization. 7311 return this.confirmPayment(paymentIntentClientSecret) 7312 7313 // Handle errors 7314 .then(confirmation => { 7315 if (confirmation.error) { 7316 this.paymentRequestButtonEvent.complete('fail'); 7317 this.paymentRequestButtonEvent = null; 7318 throw new Error(confirmation.error.message); 7319 } else { 7320 return confirmation.paymentIntent; 7321 } 7322 }); 7323 }) 7324 7325 // Step 4: build the final payment data 7326 .then(paymentIntent => { 7327 let paymentData = { 7328 payment_method: this.name,
7329 payment_intent_id: paymentIntent.id 7330 }; 7331 this.paymentRequestButtonEvent.complete('success'); 7332 this.paymentRequestButtonEvent = null; 7333 return paymentData; 7334 }) 7335 7336 // Catch any error 7337 .catch(error => { 7338 this.paymentRequestButtonEvent.complete('fail'); 7339 this.paymentRequestButtonEvent = null; 7340 console.error('Unable to payment process.', error.message); 7341 if (processArgs.error_handler != undefined) { 7342 processArgs.error_handler(error.message); 7343 } 7344 throw error; 7345 }); 7346 } 7347 7348 /** 7349 * 7350 * @param client_secret The client secret of the PaymentIntent. 7351 * 7352 * @see {@link https://stripe.com/docs/js/payment_intents/confirm_card_payment} 7353 * 7354 * @return {Promise} 7355 */ 7356 confirmPayment(client_secret) { 7357 return this.api.confirmCardPayment(client_secret); 7358 } 7359 7360 /** 7361 * @param {Cart} cart 7362 * @param {Object} paymentDetails Gateway settings + booking details. 7363 * @param {Object} processArgs Optional. 7364 * @param {Function} processArgs['error_handler'] Callback to log errors. 7365 * @return {Promise} 7366 */ 7367 processPayment(cart, paymentDetails, processArgs) { 7368 if (this.paymentRequestButtonEvent) { 7369 return this.proccessPaymentRequestButtonHandler(cart, processArgs); 7370 } 7371 return super.processPayment(cart, paymentDetails, processArgs); 7372 } 7373 7374 /** 7375 * @see https://stripe.com/docs/js/elements_object/create_payment_element 7376 * 7377 * @return {Object} 7378 */ 7379 createControl() { 7380 return this.elements.create(this.name, { 7381 style: this.settings.style, 7382 hidePostalCode: this.settings.hide_postal_code 7383 }); 7384 } 7385 } 7386 7387 /** 7388 * @since 1.14.0 7389 */ 7390 class SepaDebitMethod extends AbstractMethod { 7391 setupProperties() { 7392 this.name = 'sepa_debit'; 7393 this.title = __('SEPA Direct Debit', 'motopress-appointment'); 7394 } 7395 7396 /** 7397 * 7398 * @param client_secret The client secret of the PaymentIntent. 7399 * @param redirect_url_received 7400 * 7401 * @see {@link https://stripe.com/docs/js/payment_intents/confirm_sepa_debit_payment} 7402 * 7403 * @return {Promise} 7404 */ 7405 confirmPayment(client_secret, redirect_url_received) { 7406 return this.api.confirmSepaDebitPayment(client_secret); 7407 } 7408 7409 /** 7410 * @see https://stripe.com/docs/js/elements_object/create_payment_element 7411 * 7412 * @return {Promise<Object>} 7413 */ 7414 createControl() { 7415 return this.elements.create('iban', { 7416 style: this.settings.style, 7417 supportedCountries: ['SEPA'] 7418 }); 7419 } 7420 } 7421 7422 /** 7423 * @since 1.14.0 7424 */ 7425 class BancontactMethod extends AbstractMethod { 7426 setupProperties() { 7427 this.name = 'bancontact'; 7428 this.title = __('Bancontact', 'motopress-appointment'); 7429 } 7430 7431 /** 7432 * 7433 * @param client_secret The client secret of the PaymentIntent. 7434 * @param redirect_url_received 7435 * 7436 * @see {@link https://stripe.com/docs/js/payment_intents/confirm_bancontact_payment} 7437 * 7438 * @return {Promise} 7439 */ 7440 confirmPayment(client_secret, redirect_url_received) { 7441 return this.api.confirmBancontactPayment(client_secret, { 7442 return_url: redirect_url_received 7443 }, { 7444 handleActions: false 7445 }); 7446 } 7447 } 7448 7449 /** 7450 * @since 1.14.0 7451 */ 7452 class IdealMethod extends AbstractMethod { 7453 setupProperties() { 7454 this.name = 'ideal'; 7455 this.title = __('iDEAL', 'motopress-appointment'); 7456 } 7457 7458 /** 7459 * 7460 * @param client_secret The client secret of the PaymentIntent. 7461 * @param redirect_url_received 7462 * 7463 * @see {@link https://stripe.com/docs/js/payment_intents/confirm_ideal_payment} 7464 * 7465 * @return {Promise} 7466 */ 7467 confirmPayment(client_secret, redirect_url_received) { 7468 return this.api.confirmIdealPayment(client_secret, { 7469 return_url: redirect_url_received 7470 }, { 7471 handleActions: false 7472 }); 7473 } 7474 7475 /** 7476 * @see https://stripe.com/docs/js/elements_object/create_payment_element 7477 * 7478 * @return {Promise<Object>} 7479 */ 7480 createControl() { 7481 return this.elements.create('idealBank', { 7482 style: this.settings.style 7483 }); 7484 } 7485 } 7486 7487 /** 7488 * @since 1.14.0 7489 */ 7490 class GiropayMethod extends AbstractMethod { 7491 setupProperties() { 7492 this.name = 'giropay'; 7493 this.title = __('Giropay', 'motopress-appointment'); 7494 } 7495 7496 /** 7497 * 7498 * @param client_secret The client secret of the PaymentIntent. 7499 * @param redirect_url_received 7500 * 7501 * @see {@link https://stripe.com/docs/js/payment_intents/confirm_giropay_payment} 7502 * 7503 * @return {Promise} 7504 */ 7505 confirmPayment(client_secret, redirect_url_received) { 7506 return this.api.confirmGiropayPayment(client_secret, { 7507 return_url: redirect_url_received 7508 }, { 7509 handleActions: false 7510 }); 7511 } 7512 } 7513
7514 /** 7515 * @since 1.14.0 7516 */ 7517 class SofortMethod extends AbstractMethod { 7518 setupProperties() { 7519 this.name = 'sofort'; 7520 this.title = __('SOFORT', 'motopress-appointment'); 7521 } 7522 7523 /** 7524 * 7525 * @param client_secret The client secret of the PaymentIntent. 7526 * @param redirect_url_received 7527 * 7528 * @see {@link https://stripe.com/docs/js/payment_intents/confirm_sofort_payment} 7529 * 7530 * @return {Promise} 7531 */ 7532 confirmPayment(client_secret, redirect_url_received) { 7533 return this.api.confirmSofortPayment(client_secret, { 7534 return_url: redirect_url_received 7535 }, { 7536 handleActions: false 7537 }); 7538 } 7539 7540 /** 7541 * 7542 * @param {string} name 7543 * @param {string} email 7544 * @param {string} phone 7545 * 7546 * @return {Promise} paymentMethod or error 7547 */ 7548 createPaymentMethodData(name, email, phone) { 7549 let paymentMethodData = super.createPaymentMethodData(name, email, phone); 7550 paymentMethodData.sofort = { 7551 country: this.settings.country 7552 }; 7553 return paymentMethodData; 7554 } 7555 } 7556 7557 /** 7558 * @since 1.5.0 7559 */ 7560 class StripeGateway extends CustomizablePaymentGateway { 7561 /** 7562 * @since 1.5.0 7563 * @access protected 7564 */ 7565 setupProperties() { 7566 super.setupProperties(); 7567 7568 /** 7569 * @since 1.5.0 7570 * @var {Object} 7571 */ 7572 this.$gatewayPreloader = null; 7573 7574 /** 7575 * @since 1.5.0 7576 * @var {String} 7577 */ 7578 this.gatewayId = 'stripe'; 7579 7580 /** 7581 * @since 1.14.0 7582 * @var {Object[]} Stripe payment methods 7583 */ 7584 this.methods = null; 7585 7586 /** 7587 * @since 1.5.0 7588 * @var {StripeGatewayView} 7589 */ 7590 this.view = null; 7591 } 7592 7593 /** 7594 * @since 1.5.0 7595 * @since 1.6.0 Added cart parameter 7596 * 7597 * @param {Object} $mountWrapper 7598 */ 7599 constructor($mountWrapper, cart) { 7600 super($mountWrapper, cart); 7601 this.$gatewayPreloader = $mountWrapper.parent().find('.mpa-payment-gateway-title .mpa-preloader'); 7602 } 7603 7604 /** 7605 * @since 1.5.0 7606 * 7607 * @return {Promise} 7608 */ 7609 load() { 7610 this.loadPromise = super.load().then(() => { 7611 this.methods = this.createPaymentMethods(); 7612 this.view = new StripeGatewayView(this.methods); 7613 return this; 7614 }); 7615 return this.loadPromise; 7616 } 7617 7618 /** 7619 * Validation checks Terms & Conditions checkbox. Checkbox must be checked. 7620 * If validation crash, showing default html validation message. 7621 * 7622 * todo: paypal has the same method, we need to move the check to another method 7623 * 7624 * @return {Boolean} 7625 */ 7626 isValidAcceptTerms() { 7627 if (!mpapp().settings().getTermsPageIdForAcceptance()) { 7628 return true; 7629 } 7630 const acceptTermsMarkup = jQuery('.mpa-booking-step-payment .mpa-accept-terms')[0]; 7631 if (!acceptTermsMarkup.checkValidity()) { 7632 acceptTermsMarkup.reportValidity(); 7633 return false; 7634 } 7635 return true; 7636 } 7637 7638 /** 7639 * @see Similar method to StripeAPI::convertToSmallestUnit() in StripeAPI.php. 7640 * 7641 * @since 1.16.0 7642 * 7643 * Only used for wallets payments in the paymentRequest object. 7644 * This is necessary to display the correct price in the paymentRequest browser payment interface. 7645 * The real payment price is formed on the backend side when the PaymentIntent is created. 7646 * 7647 * @param {Number} amount 7648 * @param {String} currency Optional. 7649 * 7650 * @return {Number} 7651 */ 7652 convertToSmallestUnit(amount, currency) { 7653 if (!currency) { 7654 currency = mpapp().settings().getCurrency(); 7655 } 7656 7657 // See all currencies presented as links on page 7658 // https://stripe.com/docs/currencies#presentment-currencies 7659 switch (currency.toUpperCase()) { 7660 // Zero decimal currencies 7661 case 'BIF': 7662 case 'CLP': 7663 case 'DJF': 7664 case 'GNF': 7665 case 'JPY': 7666 case 'KMF': 7667 case 'KRW': 7668 case 'MGA': 7669 case 'PYG': 7670 case 'RWF': 7671 case 'UGX': 7672 case 'VND': 7673 case 'VUV': 7674 case 'XAF': 7675 case 'XOF': 7676 case 'XPF': 7677 amount = Math.floor(amount); // Remove cents 7678 break; 7679 default: 7680 amount = Math.round(amount * 100); // In cents 7681 break; 7682 } 7683 return amount; 7684 } 7685 7686 /** 7687 * @since 1.16.0 7688 * @access protected 7689 * 7690 * @param {Event} event 7691 */ 7692 onClickPaymentRequestButton(event) { 7693 if (!this.isValidAcceptTerms()) { 7694 event.preventDefault(); 7695 return; 7696 } 7697 const order = this.cart.getOrder(); 7698 let totalPrice = parseFloat(order.total); 7699 if (this.cart.paymentDetails.deposit) { 7700 totalPrice = parseFloat(order.deposit); 7701 } 7702 const totalPriceFormatted = this.convertToSmallestUnit(totalPrice, mpapp().settings().getCurrency().toLowerCase()); 7703 this.methods['card'].paymentRequest.update({ 7704 total: { 7705 amount: totalPriceFormatted, 7706 label: __('Total', 'motopress-appointment'), 7707 pending: false 7708 } 7709 }); 7710 } 7711 7712 /** 7713 * @since 1.5.0 7714 * @access protected 7715 * 7716 * @param {Event} event 7717 */ 7718 onChange(event) { 7719 this.haveErrors = !!event.error; 7720 if (this.haveErrors) { 7721 this.view.showError(event.error.message); 7722 } else { 7723 this.view.hideErrors(); 7724 } 7725 } 7726 7727 /** 7728 * @since 1.5.0 7729 * @access protected 7730 * 7731 * @param {Object} $mountWrapper 7732 */
7733 mount($mountWrapper) { 7734 this.ready().then(() => { 7735 this.view.mount($mountWrapper); 7736 this.addListeners(); 7737 }); 7738 } 7739 7740 /** 7741 * @since 1.5.0 7742 * 7743 * @param {Cart} cart 7744 * @param {Object} bookingDetails Booking and payment ID/UID. 7745 * @return {Promise} 7746 */ 7747 processPayment(cart, bookingDetails) { 7748 if (!this.isValid()) { 7749 return Promise.reject(new Error('The payment gateway is not valid.')); 7750 } 7751 this.$gatewayPreloader.removeClass('mpa-hide'); 7752 let paymentMethod = this.view.selectedMethod; 7753 let paymentDetails = jQuery.extend({ 7754 payment_method: paymentMethod 7755 }, this.settings, bookingDetails); 7756 let processArgs = { 7757 error_handler: this.view.showError.bind(this.view) 7758 }; 7759 const processPayment = this.methods[paymentMethod].processPayment(cart, paymentDetails, processArgs); 7760 return processPayment.then(paymentData => { 7761 this.$gatewayPreloader.addClass('mpa-hide'); 7762 return paymentData; 7763 }, error => { 7764 this.$gatewayPreloader.addClass('mpa-hide'); 7765 throw error; 7766 }); 7767 } 7768 7769 /** 7770 * @since 1.5.0 7771 * @access protected 7772 * 7773 * @return {Object} 7774 */ 7775 getDefaults() { 7776 return jQuery.extend(super.getDefaults(), { 7777 hide_postal_code: true, 7778 locale: 'auto', 7779 payment_methods: [], 7780 public_key: '', 7781 style: {} 7782 }); 7783 } 7784 7785 /** 7786 * @since 1.14.0 7787 * 7788 * @return {Object[]} Stripe payment methods 7789 */ 7790 createPaymentMethods() { 7791 let methods = []; 7792 const stripeApi = Stripe(this.settings.public_key, { 7793 apiVersion: "2022-11-15" 7794 }); 7795 const stripeElements = stripeApi.elements({ 7796 locale: this.settings.locale 7797 }); 7798 this.settings.payment_methods.forEach(method => { 7799 switch (method) { 7800 case 'card': 7801 methods['card'] = new CardMethod(stripeApi, stripeElements, this.settings); 7802 methods['card'].getControl().on('change', this.onChange.bind(this)); 7803 7804 // todo: better to refactor 7805 methods['card'].isCanMakePaymentRequest().then(canMakePayment => { 7806 if (canMakePayment) { 7807 methods['card'].paymentRequest.on('token', async event => methods['card'].processPaymentRequestButton(event)); 7808 methods['card'].paymentRequest.on('cancel', () => { 7809 methods['card'].paymentRequestButtonEvent = null; 7810 }); 7811 methods['card'].paymentRequestButton = methods['card'].createPaymentRequestButton(); 7812 methods['card'].paymentRequestButton.on('click', this.onClickPaymentRequestButton.bind(this)); 7813 } 7814 }); 7815 break; 7816 case 'sepa_debit': 7817 methods['sepa_debit'] = new SepaDebitMethod(stripeApi, stripeElements, this.settings); 7818 methods['sepa_debit'].getControl().on('change', this.onChange.bind(this)); 7819 break; 7820 case 'bancontact': 7821 methods['bancontact'] = new BancontactMethod(stripeApi, stripeElements, this.settings); 7822 break; 7823 case 'ideal': 7824 methods['ideal'] = new IdealMethod(stripeApi, stripeElements, this.settings); 7825 break; 7826 case 'giropay': 7827 methods['giropay'] = new GiropayMethod(stripeApi, stripeElements, this.settings); 7828 break; 7829 case 'sofort': 7830 methods['sofort'] = new SofortMethod(stripeApi, stripeElements, this.settings); 7831 break; 7832 } 7833 }); 7834 return methods; 7835 } 7836 7837 /** 7838 * @since 1.6.2 7839 */ 7840 reset() { 7841 // resetting each of methods 7842 Object.entries(this.methods).forEach(([methodName, method]) => { 7843 method.reset(); 7844 }); 7845 this.view.reset(); 7846 } 7847 } 7848 7849 /** 7850 * @since 1.6.0 7851 */ 7852 class PayPalGateway extends CustomizablePaymentGateway { 7853 /** 7854 * @since 1.6.0 7855 */ 7856 setupProperties() { 7857 super.setupProperties(); 7858 7859 /** 7860 * @var {String} 7861 */ 7862 this.gatewayId = 'paypal'; 7863 } 7864 7865 /** 7866 * @since 1.6.0 7867 */ 7868 enable() { 7869 super.enable(); 7870 if (this.isEnabled) { 7871 this.$mountWrapper.closest('form').find('.mpa-button-next').hide(); 7872 } 7873 } 7874 7875 /** 7876 * @since 1.6.0 7877 */ 7878 disable() { 7879 super.disable(); 7880 if (!this.isEnabled) { 7881 this.$mountWrapper.closest('form').find('.mpa-button-next').show(); 7882 } 7883 } 7884 7885 /** 7886 * Validation checks Terms & Conditions checkbox. Checkbox must be checked. 7887 * If validation crash, showing default html validation message. 7888 * 7889 * @return {Boolean} 7890 * 7891 * @access protected 7892 * 7893 * @since 1.14.0 7894 */ 7895 isValidAcceptTerms() { 7896 if (!mpapp().settings().getTermsPageIdForAcceptance()) { 7897 return true; 7898 } 7899 const acceptTermsMarkup = jQuery('.mpa-booking-step-payment .mpa-accept-terms')[0]; 7900 if (!acceptTermsMarkup.checkValidity()) { 7901 acceptTermsMarkup.reportValidity(); 7902 return false;
7903 } 7904 return true; 7905 } 7906 7907 /** 7908 * @since 1.6.0 7909 * @access protected 7910 * @param {Object} $mountWrapper 7911 */ 7912 mount($mountWrapper) { 7913 let self = this; 7914 self.$errorWrapper = $mountWrapper.find('.mpa-paypal-error'); 7915 self.$gatewayPreloader = $mountWrapper.parent().find('.mpa-payment-gateway-title .mpa-preloader'); 7916 paypal.Buttons({ 7917 onClick: function (data, actions) { 7918 if (0 === self.cart.getTotalPrice()) { 7919 self.paypalDetails = {}; 7920 jQuery('.mpa-booking-step-payment .mpa-actions .mpa-button-next').trigger('click'); 7921 } 7922 7923 // Rejects payment for invalid form field 7924 if (!self.isValidAcceptTerms()) { 7925 return actions.reject(); 7926 } 7927 }, 7928 /** 7929 * PayPal checkout server side integration. 7930 * Creates PayPal order via REST API request from backend with a correct price. 7931 * 7932 * @param data 7933 * @param actions 7934 * @returns {Promise<string>} Order id 7935 */ 7936 createOrder: function (data, actions) { 7937 self.$errorWrapper.addClass('mpa-hide'); 7938 self.$gatewayPreloader.removeClass('mpa-hide'); 7939 return mpa_rest_post('/payments/prepare', { 7940 payment_details: self.cart.paymentDetails 7941 }).then(orderId => { 7942 self.$gatewayPreloader.addClass('mpa-hide'); 7943 return orderId; 7944 }); 7945 }, 7946 onApprove: function (data, actions) { 7947 return actions.order.capture().then(function (details) { 7948 self.paypalDetails = details; 7949 jQuery('.mpa-booking-step-payment .mpa-actions .mpa-button-next').trigger('click'); 7950 }); 7951 }, 7952 onCancel: function (data) {}, 7953 onError: function (error) { 7954 console.log(error); 7955 self.$errorWrapper.text(self.settings.paypal_error_message); 7956 self.$errorWrapper.removeClass('mpa-hide'); 7957 } 7958 }).render($mountWrapper.find('.mpa-paypal-container')[0]); 7959 } 7960 7961 /** 7962 * @since 1.6.0 7963 * @param {Cart} cart 7964 * @param {Object} bookingDetails Booking and payment ID/UID. 7965 * @return {Promise} 7966 */ 7967 processPayment(cart, bookingDetails) { 7968 return Promise.resolve({ 7969 paypalDetails: this.paypalDetails 7970 }); 7971 } 7972 } 7973 7974 /** 7975 * @since 1.5.0 7976 */ 7977 class GatewayFactory { 7978 /** 7979 * @since 1.5.0 7980 * @since 1.6.0 Added cart parameter 7981 * 7982 * @param {Object} $paymentGateways 7983 * @return {PaymentGateway[]} 7984 */ 7985 static createGateways($paymentGateways, cart) { 7986 let activeGateways = {}; 7987 for (let gatewayId of mpapp().settings().getActiveGateways()) { 7988 let $billingFields = $paymentGateways.find('.mpa-' + gatewayId + '-payment-gateway .mpa-billing-fields'); 7989 let gateway = $billingFields.length !== 0 ? GatewayFactory.createGateway(gatewayId, $billingFields, cart) : null; 7990 if (gateway !== null) { 7991 activeGateways[gatewayId] = gateway; 7992 } 7993 } 7994 activeGateways['free'] = new FreeGateway({}, cart); 7995 return activeGateways; 7996 } 7997 7998 /** 7999 * @since 1.5.0 8000 * @since 1.6.0 Added cart parameter 8001 * @access protected 8002 * 8003 * @return {PaymentGateway|null} 8004 */ 8005 static createGateway(gatewayId, $billingFields, cart) { 8006 switch (gatewayId) { 8007 case 'manual': 8008 case 'test': 8009 case 'cash': 8010 case 'bank': 8011 return new PaymentGateway($billingFields, cart); 8012 case 'paypal': 8013 return new PayPalGateway($billingFields, cart); 8014 case 'stripe': 8015 return new StripeGateway($billingFields, cart); 8016 default: 8017 return wp.hooks.applyFilters('mpa_create_gateway', null, gatewayId, $billingFields, cart); 8018 } 8019 } 8020 } 8021 8022 /** 8023 * @since 1.5.0 8024 */ 8025 class StepPayment extends AbstractStep { 8026 /** 8027 * @since 1.5.0 8028 * @access protected 8029 */ 8030 setupProperties() { 8031 super.setupProperties(); 8032 this.lastCartHash = ''; // Track cart changes 8033 8034 this.gatewayId = ''; 8035 this.gateways = {}; // {Gateway ID: Gateway object} 8036 8037 this.bookingDetails = {}; // Booking and payment ID/UID 8038 8039 this.$form = this.$element.find('.mpa-checkout-form'); 8040 this.$order = this.$element.find('.mpa-order'); 8041 this.$billingSection = this.$element.find('.mpa-billing-details'); 8042 this.$paymentGateways = this.$billingSection.find('.mpa-payment-gateway'); 8043 this.$paymentGatewayButtons = this.$paymentGateways.find('input[name="payment_gateway_id"]'); 8044 this.$message = this.$element.find('.mpa-message').first(); 8045 this.acceptTerms = false;
8046 this.onlinePayment = false; 8047 this.isDepositDisabled = false; 8048 this.$deposit = this.$element.find('.mpa-deposit-section'); 8049 this.$depositSwitcher = this.$element.find('input[name="mpa-deposit-switcher"]'); 8050 this.$depositTable = this.$element.find('#mpa-deposit-table'); 8051 if (mpapp().settings().getTermsPageIdForAcceptance()) { 8052 this.$acceptTerms = this.$element.find('.mpa-accept-terms'); 8053 } 8054 this.couponSection = null; 8055 } 8056 8057 /** 8058 * @since 1.5.0 8059 * @access protected 8060 * 8061 * @return {String} 8062 */ 8063 theId() { 8064 return 'payment'; 8065 } 8066 8067 /** 8068 * @since 1.5.0 8069 * @access protected 8070 * 8071 * @return {Object} {Property name: Property settings} 8072 */ 8073 propertiesSchema() { 8074 return { 8075 gatewayId: { 8076 type: 'string', 8077 default: '' 8078 }, 8079 isDepositDisabled: { 8080 type: 'bool', 8081 default: false 8082 }, 8083 acceptTerms: { 8084 type: 'bool', 8085 default: false 8086 } 8087 }; 8088 } 8089 8090 /** 8091 * @param {String} message 8092 * 8093 * @since 1.19.1 8094 */ 8095 setErrorMessage(message) { 8096 this.$message.html(message); 8097 this.$message.toggleClass('mpa-hide', !message.trim().length); 8098 } 8099 8100 /** 8101 * @since 1.19.1 8102 */ 8103 clearErrorMessage() { 8104 this.setErrorMessage(''); 8105 } 8106 8107 /** 8108 * @since 1.14.0 8109 */ 8110 hideDeposit() { 8111 this.$deposit.addClass('mpa-hide'); 8112 this.$depositSwitcher.prop('disabled', true); 8113 this.isDepositDisabled = true; 8114 } 8115 8116 /** 8117 * @since 1.14.0 8118 */ 8119 showDeposit() { 8120 this.$deposit.removeClass('mpa-hide'); 8121 this.$depositSwitcher.prop('disabled', false); 8122 this.setProperty('isDepositDisabled', this.$depositSwitcher.prop('checked')); 8123 } 8124 8125 /** 8126 * @since 1.14.0 8127 */ 8128 toggleDepositSection() { 8129 const order = this.cart.getOrder(); 8130 const leftToPay = parseFloat(order.total) - parseFloat(order.deposit); 8131 if (leftToPay && this.onlinePayment) { 8132 this.showDeposit(); 8133 } else { 8134 this.hideDeposit(); 8135 } 8136 } 8137 8138 /** 8139 * Set payment gateway name to payment details. 8140 * Toggle deposit functions If it unavailable for setted gateway 8141 * 8142 * @param {string} gatewayId Payment gateway name 8143 * @param {integer} isOnlinePayment 0 or 1 8144 * @since 1.14.0 8145 */ 8146 setGatewayId(gatewayId, isOnlinePayment) { 8147 this.setProperty('gatewayId', gatewayId); 8148 this.onlinePayment = parseInt(isOnlinePayment); 8149 this.toggleDepositSection(); 8150 this.cart.setPaymentDetails({ 8151 gateway_id: this.gatewayId, 8152 deposit: !this.isDepositDisabled 8153 }); 8154 } 8155 8156 /** 8157 * @since 1.5.0 8158 * @access protected 8159 */ 8160 addListeners() { 8161 super.addListeners(); 8162 8163 // Disable submit 8164 this.$form.on('submit', event => false); 8165 this.$paymentGatewayButtons.on('change', event => { 8166 this.setGatewayId(event.target.value, event.target.dataset.isOnlinePayment); 8167 }); 8168 if (mpapp().settings().getTermsPageIdForAcceptance()) { 8169 this.$acceptTerms.on('input', event => this.setProperty('acceptTerms', event.target.checked)); 8170 } 8171 if (this.$depositSwitcher.length > 0) { 8172 this.$depositSwitcher.on('input', event => { 8173 this.$depositTable.toggleClass('mpa-hide', event.target.checked); 8174 this.setProperty('isDepositDisabled', event.target.checked); 8175 this.cart.setPaymentDetails({ 8176 deposit: !this.isDepositDisabled 8177 }); 8178 }); 8179 } 8180 this.$element.on('mpa_booking_step_disable', this.disable.bind(this)); 8181 this.$element.on('mpa_booking_step_enable', this.enable.bind(this)); 8182 this.$element.on('mpa_booking_coupon_applied', () => { 8183 this.notifyCartChanged(); 8184 this.updateOrderDetails(); 8185 this.cart.setPaymentDetails({ 8186 coupon_code: this.cart.hasCoupon() ? this.cart.coupon.getCode() : '' 8187 }); 8188 }); 8189 } 8190 8191 /** 8192 * @since 1.5.0 8193 * @access protected 8194 * 8195 * @return {Promise} 8196 */ 8197 loadEntities() { 8198 // show payment step before start loading. 8199 // preloader toggling in another place. 8200 if (!this.isLoaded) { 8201 this.$element.removeClass('mpa-hide'); 8202 } 8203 this.lastCartHash = this.cart.getHash('order'); 8204 8205 // Initialize coupon section control 8206 if (mpapp().settings().isCouponsEnabled()) { 8207 this.couponSection = new CouponSection(this.$element.find('.mpa-coupon-details'), this.cart); 8208 } 8209 8210 // Init/update the order 8211 this.updateOrderDetails(); 8212 8213 // Load gateways 8214 let awaitLoading = []; 8215 if (this.gatewayId !== 'free') { 8216 awaitLoading.push(this.loadGateways()); 8217 } else { 8218 this.loadGateways(); // Just load in the background 8219 } 8220 8221 // Draft posts 8222 awaitLoading.push(this.loadDrafts()); 8223 return Promise.all(awaitLoading).then(() => { 8224 // Actions after loaded all entities and inited gateways 8225 8226 // init default gateway by :checked markup received from backend 8227 // calling enable() for needle gateway 8228 // which mount loaded control element to checkout markup 8229 this.initDefaultGateway(); 8230 return this; 8231 }); 8232 } 8233 8234 /** 8235 * @since 1.5.0 8236 * @return {Promise} 8237 * @access protected 8238 */ 8239 reload() { 8240 // Clear previous messages 8241 if (this.couponSection) { 8242 this.couponSection.reset(); 8243 } 8244 this.clearErrorMessage(); 8245 8246 // Is the coupon still applicable to the cart? 8247 if (this.cart.hasCoupon()) { 8248 this.cart.testCoupon(); 8249 } 8250 8251 // Init/update the order 8252 this.updateOrderDetails(); 8253 8254 // Maybe notify payment gateways about cart changes 8255 if (this.cart.didChange(this.lastCartHash, 'order')) { 8256 this.lastCartHash = this.cart.getHash('order'); 8257 this.notifyCartChanged(); 8258 8259 // Update booking draft on the backend 8260 return this.loadDrafts(); 8261 } 8262 return Promise.resolve(this); 8263 } 8264 8265 /** 8266 * @since 1.6.2 8267 */ 8268 reset() { 8269 if (mpapp().settings().getTermsPageIdForAcceptance()) { 8270 this.$acceptTerms.prop('checked', false); 8271 this.resetProperty('acceptTerms'); 8272 } 8273 this.lastCartHash = ''; 8274 8275 // Enable default gateway 8276 let defaultGateway = mpapp().settings().getDefaultPaymentGateway(); 8277 this.$paymentGatewayButtons.filter(':checked').prop('checked', false); 8278 if (defaultGateway in this.gateways) { 8279 this.setProperty('gatewayId', defaultGateway); 8280 this.$paymentGatewayButtons.filter('[value="' + defaultGateway + '"]').prop('checked', true); 8281 } else { 8282 this.resetProperty('gatewayId'); 8283 } 8284 8285 // Reset payment gateways 8286 for (let gatewayId in this.gateways) { 8287 this.gateways[gatewayId].reset(); 8288 } 8289 8290 // Reset coupon section 8291 if (this.couponSection) { 8292 this.couponSection.reset(); 8293 } 8294 } 8295 8296 /** 8297 * @since 1.11.0 8298 * 8299 * @access protected 8300 */ 8301 notifyCartChanged() { 8302 for (let gatewayId in this.gateways) { 8303 this.gateways[gatewayId].onCartChange(this.cart); 8304 } 8305 } 8306 8307 /** 8308 * @since 1.5.0 8309 * @access protected 8310 */ 8311 updateOrderDetails() { 8312 this.$order.empty(); 8313 this.$order.html(mpa_tmpl_order(this.cart.getOrder())); 8314 if (this.$depositTable.length > 0) { 8315 const depositTableHtml = mpa_tmpl_deposit(this.cart.getOrder()); 8316 this.$depositTable.html(depositTableHtml); 8317 if (this.$paymentGatewayButtons.filter(':checked').length > 0) { 8318 this.toggleDepositSection(); 8319 } 8320 } 8321 8322 // Bind remove coupon button 8323 let $removeCoupon = this.$order.find('.mpa-remove-coupon'); 8324 if ($removeCoupon.length > 0) { 8325 $removeCoupon.on('click', this.removeCoupon.bind(this)); 8326 } 8327 this.toggleAvailablePaymentMethods(); 8328 } 8329 8330 /** 8331 * @since 1.11.0 8332 * 8333 * @access protected 8334 * 8335 * @param {Event} event 8336 */ 8337 removeCoupon(event) { 8338 event.preventDefault(); 8339 event.stopPropagation(); 8340 this.cart.removeCoupon(); 8341 this.couponSection.clearMessage(); 8342 this.cart.setPaymentDetails({ 8343 coupon_code: '' 8344 }); 8345 this.notifyCartChanged(); 8346 this.updateOrderDetails(); 8347 } 8348 8349 /** 8350 * Hide unavailable payment methods for order. 8351 * 8352 * @since 1.14.0 8353 * @access protected 8354 */ 8355 toggleAvailablePaymentMethods() { 8356 const isFreeOrder = this.cart.getTotalPrice() === 0; 8357 if (isFreeOrder) { 8358 this.setGatewayId('free', false); 8359 } else { 8360 const $currentGateway = this.$paymentGatewayButtons.filter(':checked'); 8361 if ($currentGateway.length > 0) { 8362 this.setGatewayId($currentGateway[0].value, $currentGateway[0].dataset.isOnlinePayment); 8363 } 8364 } 8365 this.$billingSection.toggleClass('mpa-hide', isFreeOrder); 8366 this.$paymentGatewayButtons.prop('required', !isFreeOrder); 8367 } 8368 8369 /** 8370 * @since 1.5.0 8371 * @access protected 8372 * 8373 * @return {Promise[]} 8374 */ 8375 loadGateways() { 8376 let $paymentGateways = this.$billingSection.find('.mpa-payment-gateways'); 8377 this.gateways = GatewayFactory.createGateways($paymentGateways, this.cart); 8378 let awaitGateways = []; 8379 for (let gatewayId in this.gateways) { 8380 awaitGateways.push(this.gateways[gatewayId].load()); 8381 } 8382 return awaitGateways; 8383 } 8384 8385 /** 8386 * @since 1.5.0 8387 * @access protected 8388 * 8389 * @return {Promise} 8390 */ 8391 loadDrafts() { 8392 let cartData = this.cart.toArray(); 8393 cartData.payment = true; 8394 return mpa_rest_post('/bookings/draft', cartData).then(drafts => { 8395 this.bookingDetails = drafts; 8396 /** 8397 * @todo: param paymentDetails[payment_id] need only for backward compatibility with a mpa-woocommerce addon v1.0.0 8398 */ 8399 const paymentDetails = { 8400 booking_id: drafts.booking_id, 8401 payment_id: drafts.payment_id 8402 }; 8403 this.cart.setPaymentDetails(paymentDetails); 8404 }, error => { 8405 this.setErrorMessage(error.message); 8406 }).then(() => { 8407 this.enableGateways(); 8408 return this; 8409 }); 8410 } 8411 8412 /** 8413 * @since 1.5.0 8414 * @access protected 8415 */ 8416 enableGateways() { 8417 this.$paymentGatewayButtons.prop('disabled', false); 8418 } 8419 8420 /** 8421 * @since 1.15.0 8422 * @access protected 8423 */ 8424 initDefaultGateway() { 8425 let $defaultGateway = this.$paymentGatewayButtons.filter(':checked'); 8426 if ($defaultGateway.length > 0) { 8427 this.gateways[$defaultGateway.val()].enable(); 8428 } 8429 } 8430 8431 /** 8432 * @since 1.5.0 8433 * 8434 * @return {Boolean} 8435 */ 8436 isValidInput() { 8437 return this.isValidGatewayId() && this.isValidGateway() && this.isValidAcceptTerms(); 8438 } 8439 8440 /** 8441 * @since 1.5.0 8442 * @access protected 8443 * 8444 * @return {Boolean} 8445 */ 8446 isValidGatewayId() { 8447 return this.gatewayId !== ''; 8448 } 8449 8450 /** 8451 * @since 1.5.0 8452 * 8453 * @access protected 8454 * 8455 * @return {Boolean} 8456 */ 8457 isValidGateway() { 8458 if (this.gatewayId in this.gateways) { 8459 return this.gateways[this.gatewayId].isValid(); 8460 } else { 8461 return true; 8462 } 8463 } 8464 8465 /** 8466 * @return {Boolean} 8467 * 8468 * @access protected 8469 * 8470 * @since 1.10.2 8471 */ 8472 isValidAcceptTerms() { 8473 return !mpapp().settings().getTermsPageIdForAcceptance() || this.acceptTerms; 8474 } 8475 8476 /** 8477 * @since 1.5.0 8478 * @access protected 8479 * 8480 * @param {String} propertyName 8481 * @param {*} newValue 8482 * @param {*} oldValue 8483 */ 8484 afterUpdate(propertyName, newValue, oldValue) { 8485 // Toggle gateways 8486 if (oldValue in this.gateways) { 8487 this.gateways[oldValue].disable(); 8488 } 8489 if (newValue in this.gateways) { 8490 this.gateways[newValue].enable(); 8491 } 8492 } 8493 8494 /** 8495 * @since 1.5.0 8496 * @access protected 8497 */ 8498 react() { 8499 super.react(); 8500 8501 // Always enable submit button to allow validation messages 8502 this.$buttonNext.prop('disabled', false); 8503 } 8504 8505 /** 8506 * @since 1.5.0 8507 * @access protected 8508 * 8509 * @return {undefined|Promise} 8510 */ 8511 maybeSubmit() { 8512 // Disable coupons section 8513 if (this.couponSection) { 8514 this.couponSection.disable(); 8515 } 8516 if (this.gatewayId in this.gateways) { 8517 let gateway = this.gateways[this.gatewayId]; 8518 let result = gateway.processPayment(this.cart, this.bookingDetails); 8519 8520 // If a Promise 8521 if (typeof result === 'object' && typeof result.then === 'function') { 8522 result.then(paymentData => { 8523 this.cart.setPaymentDetails(paymentData); 8524 return paymentData; 8525 }, error => { 8526 this.setErrorMessage(error.message); 8527 } 8528 8529 // Leave errors to AbstractStep.submit() or re-throw it, so 8530 // AbstractStep may cancel submission 8531 ); 8532 } 8533 8534 return result; 8535 } 8536 } 8537 8538 /** 8539 * @since 1.11.0 8540 * 8541 * @access protected 8542 */ 8543 cancelSubmission() { 8544 super.cancelSubmission(); 8545 if (this.couponSection) { 8546 this.couponSection.enable(); 8547 } 8548 } 8549 } 8550
8551 /** 8552 * @param {Object} element jQuery or DOM element. 8553 * @param {Object} args 8554 * @return {Object} 8555 * 8556 * @since 1.2.1 8557 */ 8558 function mpa_flatpickr(element, args) { 8559 let locale = args.locale || mpapp().settings().getFlatpickrLocale(); 8560 let l10n = flatpickr.l10ns[locale] || locale; 8561 8562 // Use first day of the week value from Settings > General > Week Starts On 8563 if (typeof l10n == 'object') { 8564 l10n.firstDayOfWeek = mpapp().settings().getFirstDayOfWeek(); 8565 } 8566 8567 // Merge args 8568 let defaultArgs = { 8569 formatDate: mpa_format_date, 8570 inline: true, 8571 locale: l10n, 8572 monthSelectorType: 'static', 8573 showMonths: 1 8574 }; 8575 args = jQuery.extend({}, defaultArgs, args); 8576 8577 // Create instance 8578 let instance = null; 8579 if (element instanceof jQuery) { 8580 instance = flatpickr(element[0], args); 8581 } else { 8582 instance = flatpickr(element, args); 8583 } 8584 return instance; 8585 } 8586 8587 /** 8588 * @since 1.0 8589 */ 8590 class StepPeriod extends AbstractStep { 8591 /** 8592 * @access protected 8593 * 8594 * @since 1.0 8595 */ 8596 setupProperties() { 8597 super.setupProperties(); 8598 this.cartItem = null; 8599 this.lastHash = ''; // Track cart changes 8600 8601 this.monthSlots = {}; // {'YYYY-MM': {'Y-m-d': Time slots}}, where time slots 8602 // is {Time period: Array of [Employee ID, Location ID]}. 8603 // For example: {'10:00 - 11:00': [[19, 21], [89, 21]], ...} 8604 8605 this.date = ''; 8606 this.time = ''; 8607 this.datepicker = null; 8608 this.$dateWrapper = this.$element.find('.mpa-date-wrapper'); 8609 this.$dateInput = this.$element.find('.mpa-date'); 8610 this.$timeWrapper = this.$element.find('.mpa-time-wrapper'); 8611 this.$times = this.$timeWrapper.find('.mpa-times'); 8612 8613 /** 8614 * Recursion counters 8615 * @since 1.19.0 8616 */ 8617 this.selectFirstDateTimeSlotRecursionCounter = 0; 8618 this.selectFirstDateTimeSlotRecursionCounterMax = 12; 8619 this.isAlreadySelectedFirstDateTimeSlotAfterFirstRenderCalendar = false; 8620 } 8621 8622 /** 8623 * @return {String} 8624 * 8625 * @access protected 8626 * 8627 * @since 1.0 8628 */ 8629 theId() { 8630 return 'period'; 8631 } 8632 8633 /** 8634 * @return {String} 'cart'|'cart item' 8635 * 8636 * @since 1.9.0 8637 */ 8638 getCartContext() { 8639 return 'cart item'; 8640 } 8641 8642 /** 8643 * @return {Object} 8644 * 8645 * @access protected 8646 * 8647 * @since 1.0 8648 */ 8649 propertiesSchema() { 8650 return { 8651 date: { 8652 type: 'string', 8653 default: '' 8654 }, 8655 time: { 8656 type: 'string', 8657 default: '' 8658 } 8659 }; 8660 } 8661 8662 /** 8663 * @access protected 8664 * 8665 * @since 1.0 8666 */ 8667 addListeners() { 8668 super.addListeners(); 8669 this.$dateInput.on('change', event => this.setProperty('date', event.target.value)); 8670 } 8671 8672 /** 8673 * @return {Promise} 8674 * 8675 * @access protected 8676 * 8677 * @since 1.0 8678 */ 8679 loadEntities() { 8680 this.cartItem = this.cart.getActiveItem(); 8681 this.lastHash = this.cartItem.getHash('availability'); 8682 return Promise.resolve(this); 8683 } 8684 8685 /** 8686 * @access protected 8687 * 8688 * @since 1.0 8689 */ 8690 reload() { 8691 // Did something changed? 8692 if (this.cartItem.didChange(this.lastHash, 'availability')) { 8693 // Reset all and reload 8694 this.$element.removeClass('mpa-loaded'); 8695 this.resetDate();
8695 // This also resets the time 8696 this.readyPromise = this.loadEntities(); 8697 8698 // Reset saved time slots 8699 this.monthSlots = {}; 8700 8701 // Re-enable days 8702 if (this.datepicker != null) { 8703 this.setEnabledDays([]); 8704 this.readyPromise.finally(() => this.resetEnabledDays()); 8705 } 8706 return this.readyPromise; 8707 } else { 8708 return Promise.resolve(this); 8709 } 8710 } 8711 8712 /** 8713 * Reset step before a new item. 8714 * 8715 * @since 1.4.0 8716 */ 8717 reset() { 8718 this.cartItem = this.cart.getActiveItem(); 8719 this.lastHash = ''; 8720 this.monthSlots = {}; 8721 this.resetDate(); 8722 } 8723 8724 /** 8725 * @return {Boolean} 8726 * 8727 * @since 1.0 8728 */ 8729 isValidInput() { 8730 return this.date != '' && this.time != ''; 8731 } 8732 8733 /** 8734 * @access protected 8735 * 8736 * @since 1.0 8737 */ 8738 resetDate() { 8739 // Will also reset the time in afterUpdate() 8740 this.resetProperty('date'); 8741 } 8742 8743 /** 8744 * @access protected 8745 * 8746 * @since 1.0 8747 */ 8748 resetTime() { 8749 this.$times.empty(); 8750 this.resetProperty('time'); 8751 } 8752 8753 /** 8754 * @param {Array} 8755 * 8756 * @since 1.0 8757 */ 8758 setEnabledDays(allowedDays) { 8759 if (!mpa_empty(allowedDays, true)) { 8760 this.datepicker.set('enable', allowedDays); 8761 } else { 8762 // Just a date in the past to disable all days. With [] Flatpickr 8763 // allows all dates and disables nothing 8764 this.datepicker.set('enable', ['2000-01-01']); 8765 } 8766 } 8767 8768 /** 8769 * Executed only when the new value is different from the old one. 8770 * 8771 * @param {String} propertyName 8772 * @param {*} newValue 8773 * @param {*} oldValue 8774 * 8775 * @access protected 8776 * 8777 * @since 1.0 8778 */ 8779 afterUpdate(propertyName, newValue, oldValue) { 8780 if (propertyName == 'date') { 8781 if (newValue == '') { 8782 this.resetTime(); 8783 } else { 8784 this.resetTimeSlots(); 8785 } 8786 } 8787 } 8788 8789 /** 8790 * @access protected 8791 * 8792 * @since 1.0 8793 */ 8794 react() { 8795 super.react(); 8796 8797 // Toggle time slots 8798 this.$timeWrapper.toggleClass('mpa-hide', this.date == ''); 8799 } 8800 8801 /** 8802 * @access protected 8803 * 8804 * @since 1.0 8805 */ 8806 showReady() { 8807 super.showReady(); 8808 8809 // Show datepicker 8810 if (this.datepicker == null) { 8811 this.showDatepicker(); 8812 8813 // Enable days for current month 8814 this.resetEnabledDays(); 8815 } 8816 } 8817 8818 /** 8819 * @access protected 8820 * 8821 * @since 1.0 8822 */ 8823 showDatepicker() { 8824 this.datepicker = mpa_flatpickr(this.$dateInput, this.getDatepickerArgs()); 8825 } 8826 8827 /** 8828 * @access protected 8829 */ 8830 getDatepickerArgs() { 8831 return { 8832 minDate: mpapp().settings().getBusinessDate(), 8833 // Don't allow to go to 8834 // the previous months 8835 onMonthChange: () => this.resetEnabledDays() 8836 }; 8837 } 8838 8839 /** 8840 * @access protected 8841 * 8842 * @since 1.0 8843 */ 8844 maybeSubmit() { 8845 let cartItem = this.cartItem; 8846 cartItem.date = mpa_parse_date(this.date); 8847 cartItem.time = new TimePeriod(this.time); 8848 8849 // Autoselect employee/location, if needed 8850 if (cartItem.employee === null || cartItem.location === null) { 8851 let autoselectedIds = this.autoselectIds(); 8852 let autoEmployeeId = autoselectedIds[0]; 8853 let autoLocationId = autoselectedIds[1]; 8854 if (cartItem.employee === null) { 8855 cartItem.setEmployee(autoEmployeeId, 'no-reset'); 8856 } 8857 if (cartItem.location === null) { 8858 cartItem.setLocation(autoLocationId, 'no-reset'); 8859 } 8860 } 8861 document.dispatchEvent(new CustomEvent('mpa_add_to_cart', { 8862 detail: { 8863 cartItem: cartItem, 8864 currencyCode: mpapp().settings().getCurrency() 8865 } 8866 })); 8867 document.dispatchEvent(new CustomEvent('mpa_view_cart', { 8868 detail: { 8869 cart: this.cart, 8870 currencyCode: mpapp().settings().getCurrency() 8871 } 8872 })); 8873 } 8874 8875 /** 8876 * After loading time slots of month, we set the first available timeslot as the initial value of the calendar. 8877 * 8878 * @since 1.19.0 8879 */ 8880 selectFirstDateTimeSlot() { 8881 let year = this.datepicker.currentYear; 8882 let month = this.datepicker.currentMonth; 8883 let monthKey = this.getMonthKey(year, month); 8884 const slotsForThisMonth = this.monthSlots[monthKey]; 8885 if (slotsForThisMonth && Object.keys(slotsForThisMonth).length > 0) { 8886 // Get the first available date and time 8887 const firstDate = Object.keys(slotsForThisMonth)[0]; 8888 const firstTimeSlot = Object.keys(slotsForThisMonth[firstDate])[0]; 8889 8890 // Programmatically set the date value for Flatpickr and call the change handler 8891 this.datepicker.setDate(firstDate, true); 8892 const targetTimeElement = this.$times.children('.mpa-time-period').filter((index, elem) => { 8893 return elem.getAttribute('date-time') === firstTimeSlot; 8894 }); 8895 targetTimeElement.trigger('click'); 8896 this.isAlreadySelectedFirstDateTimeSlotAfterFirstRenderCalendar = true; 8897 } else { 8898 // Make scroll of calendar for first render after loading 8899 // and skip scroll of calendar for calendar scroll by user actions 8900 if (this.isAlreadySelectedFirstDateTimeSlotAfterFirstRenderCalendar === true) { 8901 return; 8902 } 8903 8904 // Prevent recursive loop 8905 if (this.selectFirstDateTimeSlotRecursionCounter >= this.selectFirstDateTimeSlotRecursionCounterMax) { 8906 this.datepicker.changeMonth(-this.selectFirstDateTimeSlotRecursionCounter); 8907 this.isAlreadySelectedFirstDateTimeSlotAfterFirstRenderCalendar = true; 8908 return; 8909 } 8910 this.selectFirstDateTimeSlotRecursionCounter += 1; 8911 8912 // If no available slots for the current month, move to the next month 8913 this.datepicker.changeMonth(1); 8914 8915 // Reload timeslots for new month and then again call resetEnabledDays() => selectFirstDateTimeSlot() method to try again with the next month 8916 // To be careful. This is recursion. 8917 this.reload(); 8918 } 8919 } 8920 8921 /** 8922 * @return {Number[]} [Employee ID, Location ID] 8923 * 8924 * @access protected 8925 * 8926 * @since 1.2.1 8927 */ 8928 autoselectIds() { 8929 let ids = [0, 0]; 8930 let monthKey = this.getCurrentMonthKey(); 8931 if (this.monthSlots[monthKey] && this.monthSlots[monthKey][this.date]) { 8932 let timeSlots = this.monthSlots[monthKey][this.date]; 8933 8934 // Search for the proper period 8935 for (let timeStr in timeSlots) { 8936 if (timeStr === this.time) { 8937 let employees = timeSlots[timeStr]; 8938 8939 // Get the first pair 8940 ids[0] = employees[0][0]; // Copy values, not 8941 ids[1] = employees[0][1]; // the array pointer 8942 8943 break; 8944 } 8945 } 8946 } 8947 return ids; 8948 } 8949 8950 /** 8951 * @access protected 8952 * 8953 * @since 1.0 8954 */ 8955 resetEnabledDays() { 8956 // Reset date & time 8957 this.resetDate();
8957 // This also resets the time 8958 8959 // Disable all days 8960 this.setEnabledDays([]); 8961 8962 // Add preloader 8963 this.$dateWrapper.removeClass('mpa-loaded'); 8964 8965 // Get new enabled days 8966 let year = this.datepicker.currentYear; 8967 let month = this.datepicker.currentMonth; 8968 let monthKey = this.getMonthKey(year, month); 8969 8970 // Query time slots 8971 let queryPromise = null; 8972 if (!this.monthSlots[monthKey]) { 8973 // Query slots for new month 8974 let serviceId = this.cartItem.service.id; 8975 let dateFrom = new Date(year, month, 1); 8976 let dateTo = new Date(year, month + 1, 0); 8977 queryPromise = mpa_time_slots(serviceId, dateFrom, dateTo, this.getTimeSlotsQueryArgs()); 8978 } else { 8979 // Get already loaded month 8980 queryPromise = Promise.resolve(this.monthSlots[monthKey]); 8981 } 8982 8983 // Update enabled days 8984 queryPromise.then(daySlots => { 8985 // Save slots for future 8986 this.monthSlots[monthKey] = daySlots; 8987 this.setEnabledDays(Object.keys(daySlots)); 8988 this.$dateWrapper.addClass('mpa-loaded'); 8989 this.selectFirstDateTimeSlot(); 8990 }); 8991 } 8992 8993 /** 8994 * @access protected 8995 */ 8996 getTimeSlotsQueryArgs() { 8997 return { 8998 employees: this.cartItem.getAvailableEmployeeIds(), 8999 locations: this.cartItem.getAvailableLocationIds(), 9000 exclude: this.cart.toArray('items') 9001 }; 9002 } 9003 9004 /** 9005 * @access protected 9006 * 9007 * @since 1.0 9008 */ 9009 resetTimeSlots() { 9010 // Clear time 9011 this.resetTime(); 9012 9013 // Get time slots 9014 let timeSlots = {}; 9015 let monthKey = this.getCurrentMonthKey(); 9016 if (this.monthSlots[monthKey][this.date] != undefined) { 9017 timeSlots = this.monthSlots[monthKey][this.date]; 9018 } 9019 9020 // Add buttons to the $times 9021 let slotsAdded = 0; 9022 for (let timeStr in timeSlots) { 9023 let period = new TimePeriod(timeStr); // '10:00 - 11:00' 9024 9025 // '10:00<span class="mpa-period-end-time"> - 11:00</span>', so we 9026 // can use CSS to hide second part in narrow columns 9027 let periodLabel = period.toString('public', '<span class="mpa-period-end-time"> - ') + '</span>'; 9028 9029 // Add button 9030 let periodButton = mpa_tmpl_button(periodLabel, { 9031 'class': 'button button-secondary mpa-time-period', 9032 'date-time': timeStr 9033 }); 9034 this.$times.append(periodButton); 9035 slotsAdded++; 9036 } 9037 9038 // Add listeners or error message 9039 if (slotsAdded > 0) { 9040 this.$times.children('.mpa-time-period').on('click', event => this.onTime(event, event.currentTarget)); 9041 } else { 9042 this.$times.text(__('Sorry, but we were unable to allocate time slots for the date you selected.', 'motopress-appointment')); 9043 } 9044 } 9045 9046 /** 9047 * @param {Number} year 9048 * @param {Number} month 9049 * @return {String} 9050 * 9051 * @access protected 9052 * 9053 * @since 1.2.1 9054 */ 9055 getMonthKey(year, month) { 9056 if (month <= 8) { 9057 // '2021-04' 9058 return year + '-0' + (month + 1); 9059 } else { 9060 // '2021-10' 9061 return year + '-' + (month + 1); 9062 } 9063 } 9064 9065 /** 9066 * @return {String} 9067 * 9068 * @access protected 9069 * 9070 * @since 1.2.1 9071 */ 9072 getCurrentMonthKey() { 9073 if (this.date !== '') { 9074 let date = mpa_parse_date(this.date); 9075 return this.getMonthKey(date.getFullYear(), date.getMonth()); 9076 } else { 9077 return '2000-01'; 9078 } 9079 } 9080 9081 /** 9082 * @param {Event} event 9083 * @param {Button} button Clicked button. 9084 * 9085 * @access protected 9086 * 9087 * @since 1.0 9088 */ 9089 onTime(event, button) { 9090 // Toggle class ".mpa-time-period-selected" 9091 this.$times.children('.mpa-time-period-selected').removeClass('mpa-time-period-selected'); 9092 button.classList.add('mpa-time-period-selected'); 9093 9094 // Update time property 9095 this.setProperty('time', button.getAttribute('date-time')); 9096 } 9097 } 9098 9099 /** 9100 * @since 1.0 9101 */ 9102 class StepServiceForm extends AbstractStep { 9103 /** 9104 * @access protected 9105 * 9106 * @since 1.0 9107 */ 9108 setupProperties() { 9109 super.setupProperties(); 9110 this.availability = new AvailabilityService(); 9111 this.category = ''; 9112 this.serviceId = 0; 9113 this.employeeId = 0; 9114 this.locationId = 0; 9115 this.isHiddenStep = true; 9116 this.$form = this.$element.find('.mpa-service-form'); 9117 this.$categories = this.$element.find('.mpa-service-category-wrapper'); 9118 this.$services = this.$element.find('.mpa-service-wrapper'); 9119 this.$employees = this.$element.find('.mpa-employee-wrapper'); 9120 this.$locations = this.$element.find('.mpa-location-wrapper'); 9121 this.$selects = this.$element.find('.mpa-input-wrapper select'); 9122 this.$categoriesSelect = this.$selects.filter('.mpa-service-category'); 9123 this.$servicesSelect = this.$selects.filter('.mpa-service'); 9124 this.$employeesSelect = this.$selects.filter('.mpa-employee'); 9125 this.$locationsSelect = this.$selects.filter('.mpa-location'); 9126 this.unselectedServiceText = this.$servicesSelect.children('[value=""]').text(); 9127 this.unselectedOptionText = this.$selects.filter('.mpa-optional-select').first().find('option:first').text(); 9128 } 9129 9130 /** 9131 * @return {String} 9132 * 9133 * @access protected 9134 * 9135 * @since 1.0 9136 */ 9137 theId() { 9138 return 'service-form'; 9139 } 9140 9141 /** 9142 * @return {String} 'cart'|'cart item' 9143 * 9144 * @since 1.9.0 9145 */ 9146 getCartContext() { 9147 return 'cart item'; 9148 } 9149 9150 /** 9151 * @return {Object} 9152 * 9153 * @access protected 9154 * 9155 * @since 1.0 9156 */ 9157 propertiesSchema() { 9158 return { 9159 category: { 9160 type: 'string', 9161 default: '' 9162 }, 9163 serviceId: { 9164 type: 'integer', 9165 default: 0 9166 }, 9167 employeeId: { 9168 type: 'integer', 9169 default: 0 9170 }, 9171 locationId: { 9172 type: 'integer', 9173 default: 0 9174 } 9175 }; 9176 } 9177 9178 /** 9179 * @access protected 9180 * 9181 * @since 1.0 9182 */ 9183 addListeners() { 9184 super.addListeners(); 9185 this.$form.on('submit', this.submitForm.bind(this)); 9186 this.$categoriesSelect.on('change', event => this.setProperty('category', event.target.value)); 9187 this.$servicesSelect.on('change', event => this.setProperty('serviceId', event.target.value)); 9188 this.$employeesSelect.on('change', event => this.setProperty('employeeId', event.target.value)); 9189 this.$locationsSelect.on('change', event => this.setProperty('locationId', event.target.value)); 9190 } 9191 isHiddenElementByProp($element) { 9192 const isHidden = $element.attr('data-is-hidden'); 9193 if (typeof isHidden !== 'undefined' && isHidden !== 'false') { 9194 return true; 9195 } 9196 return false;
9197 } 9198 initCategoriesSelect() { 9199 if (this.$categoriesSelect.length == 0) { 9200 return; // The select is disabled by parameter show_* 9201 } 9202 9203 this.updateCategorySchema(); 9204 let value = this.$categoriesSelect.val(); 9205 let isHiddenSelect = this.isHiddenElementByProp(this.$servicesSelect); 9206 if (this.$categoriesSelect.attr('data-default')) { 9207 const defaultValue = this.$categoriesSelect.attr('data-default'); 9208 if (this.isValidCategoryBySchema(defaultValue)) { 9209 value = defaultValue; 9210 } else { 9211 isHiddenSelect = false; 9212 } 9213 } 9214 this.setProperty('category', value); 9215 this.renderCategorySelect(); 9216 if (!isHiddenSelect) { 9217 this.isHiddenStep = false; 9218 } 9219 this.$categories.toggleClass('mpa-hide', isHiddenSelect); 9220 } 9221 initServicesSelect() { 9222 if (this.$servicesSelect.length == 0) { 9223 return; // The select is disabled by parameter show_* 9224 } 9225 9226 this.updateServiceSchema(); 9227 let value = this.$servicesSelect.val(); 9228 let isHiddenSelect = this.isHiddenElementByProp(this.$servicesSelect); 9229 if (this.$servicesSelect.attr('data-default')) { 9230 const defaultValue = mpa_intval(this.$servicesSelect.attr('data-default')); 9231 if (this.isValidServiceBySchema(defaultValue)) { 9232 value = defaultValue; 9233 } else { 9234 isHiddenSelect = false; 9235 } 9236 } 9237 this.setProperty('serviceId', value); 9238 this.renderServiceSelect(); 9239 if (!isHiddenSelect) { 9240 this.isHiddenStep = false; 9241 } 9242 this.$services.toggleClass('mpa-hide', isHiddenSelect); 9243 } 9244 initEmployeesSelect() { 9245 if (this.$employeesSelect.length == 0) { 9246 return; // The select is disabled by parameter show_* 9247 } 9248 9249 this.updateEmployeeSchema(); 9250 let value = this.$employeesSelect.val(); 9251 let isHiddenSelect = this.isHiddenElementByProp(this.$employeesSelect); 9252 if (this.$employeesSelect.attr('data-default')) { 9253 const defaultValue = mpa_intval(this.$employeesSelect.attr('data-default')); 9254 if (this.isValidEmployeeBySchema(defaultValue)) { 9255 value = defaultValue; 9256 } else { 9257 isHiddenSelect = false; 9258 } 9259 } 9260 this.setProperty('employeeId', value); 9261 this.renderEmployeeSelect(); 9262 if (!isHiddenSelect) { 9263 this.isHiddenStep = false; 9264 } 9265 this.$employees.toggleClass('mpa-hide', isHiddenSelect); 9266 } 9267 initLocationsSelect() { 9268 if (this.$locationsSelect.length == 0) { 9269 return; // The select is disabled by parameter show_* 9270 } 9271 9272 this.updateLocationSchema(); 9273 let value = this.$locationsSelect.val(); 9274 let isHiddenSelect = this.isHiddenElementByProp(this.$locationsSelect); 9275 if (this.$locationsSelect.attr('data-default')) { 9276 const defaultValue = mpa_intval(this.$locationsSelect.attr('data-default')); 9277 if (this.isValidLocationBySchema(defaultValue)) { 9278 value = defaultValue; 9279 } else { 9280 isHiddenSelect = false; 9281 } 9282 } 9283 this.setProperty('locationId', value); 9284 this.renderLocationSelect(); 9285 if (!isHiddenSelect) { 9286 this.isHiddenStep = false; 9287 } 9288 this.$locations.toggleClass('mpa-hide', isHiddenSelect); 9289 } 9290 9291 /** 9292 * @return {Promise} 9293 * 9294 * @access protected 9295 * 9296 * @since 1.0 9297 */ 9298 loadEntities() { 9299 return this.availability.ready().finally(() => { 9300 // Hide other empty selects 9301 // or hide selects with the "data-is-hidden" setting, excluding unselected and required selects. 9302 this.initServicesSelect(); 9303 this.initCategoriesSelect(); // needs to be initialized after this.initServicesSelect(), because hiding the field depends on hiding the services select field. 9304 this.initEmployeesSelect(); 9305 this.initLocationsSelect(); 9306 return this; 9307 }); 9308 } 9309 9310 /** 9311 * Reset step before a new item. 9312 * 9313 * @since 1.4.0 9314 */ 9315 reset() { 9316 let $selects = { 9317 category: this.$categoriesSelect, 9318 serviceId: this.$servicesSelect, 9319 employeeId: this.$employeesSelect, 9320 locationId: this.$locationsSelect 9321 }; 9322 this.preventReact = true; 9323 9324 // Set default values 9325 for (let propertyName in $selects) { 9326 let $select = $selects[propertyName]; 9327 let defaultValue = $select.attr('data-default'); 9328 if (defaultValue) { 9329 this.setProperty(propertyName, defaultValue); 9330 } else { 9331 this.resetProperty(propertyName); 9332 } 9333 } 9334 this.preventReact = false;
9335 9336 // Render changes if this step is active 9337 if (this.isActive) { 9338 this.react(); 9339 } 9340 } 9341 9342 /** 9343 * @return {Boolean} 9344 * 9345 * @since 1.0 9346 */ 9347 isValidInput() { 9348 return this.serviceId != 0; 9349 } 9350 9351 /** 9352 * @since 1.19.0 9353 * 9354 * Save allowed options for validateProperty() 9355 */ 9356 updateCategorySchema() { 9357 const categories = this.availability.getAvailableServiceCategories(this.serviceId); 9358 this.schema.category.options = Object.keys(categories); 9359 } 9360 9361 /** 9362 * @since 1.19.0 9363 * 9364 * Save allowed options for validateProperty() 9365 */ 9366 updateServiceSchema() { 9367 const services = this.availability.getAvailableServices(this.category, this.locationId, this.employeeId); 9368 this.schema.serviceId.options = Object.keys(services).map(mpa_intval); 9369 } 9370 9371 /** 9372 * @since 1.19.0 9373 * 9374 * Save allowed options for validateProperty() 9375 */ 9376 updateEmployeeSchema() { 9377 const employees = this.availability.getAvailableEmployees(this.serviceId, this.locationId); 9378 this.schema.employeeId.options = Object.keys(employees).map(mpa_intval); 9379 } 9380 9381 /** 9382 * @since 1.19.0 9383 * 9384 * Save allowed options for validateProperty() 9385 */ 9386 updateLocationSchema() { 9387 const locations = this.availability.getAvailableLocations(this.serviceId, this.employeeId); 9388 this.schema.locationId.options = Object.keys(locations).map(mpa_intval); 9389 } 9390 9391 /** 9392 * @param {String} categorySlug 9393 * @return {Boolean} 9394 */ 9395 isValidCategoryBySchema(categorySlug) { 9396 return this.schema.category.options.includes(categorySlug); 9397 } 9398 9399 /** 9400 * @param {Number} serviceId 9401 * @return {Boolean} 9402 */ 9403 isValidServiceBySchema(serviceId) { 9404 return this.schema.serviceId.options.includes(serviceId); 9405 } 9406 9407 /** 9408 * @param {Number} locationId 9409 * @return {Boolean} 9410 */ 9411 isValidLocationBySchema(locationId) { 9412 return this.schema.locationId.options.includes(locationId); 9413 } 9414 9415 /** 9416 * @param {Number} locationId 9417 * @return {Boolean} 9418 */ 9419 isValidEmployeeBySchema(employeeId) { 9420 return this.schema.employeeId.options.includes(employeeId); 9421 } 9422 9423 /** 9424 * Executed only when the new value is different from the old one. 9425 * 9426 * @param {String} propertyName 9427 * @param {*} newValue 9428 * @param {*} oldValue 9429 * 9430 * @access protected 9431 * 9432 * @since 1.0 9433 */ 9434 afterUpdate(propertyName, newValue, oldValue) { 9435 this.updateCategorySchema(); 9436 this.updateServiceSchema(); 9437 this.updateEmployeeSchema(); 9438 this.updateLocationSchema(); 9439 } 9440 9441 /** 9442 * @access protected 9443 * 9444 * @since 1.0 9445 */ 9446 react() { 9447 super.react(); 9448 9449 // Update dependent <select>'s 9450 this.$categoriesSelect.val(this.category || ''); 9451 this.$servicesSelect.val(this.serviceId || ''); 9452 this.$employeesSelect.val(this.employeeId); 9453 this.$locationsSelect.val(this.locationId); 9454 this.$categoriesSelect.toggleClass('mpa-selected', this.category != ''); 9455 this.$servicesSelect.toggleClass('mpa-selected', this.serviceId != 0); 9456 this.$employeesSelect.toggleClass('mpa-selected', this.employeeId != 0); 9457 this.$locationsSelect.toggleClass('mpa-selected', this.locationId != 0); 9458 9459 // re-renden select options 9460 this.renderCategorySelect(); 9461 this.renderServiceSelect(); 9462 this.renderEmployeeSelect(); 9463 this.renderLocationSelect(); 9464 9465 // Always enable submit button to allow validation messages 9466 this.$buttonNext.prop('disabled', false); 9467 } 9468 9469 /** 9470 * @since 1.19.0 9471 * @access protected 9472 */ 9473 renderCategorySelect() { 9474 this.preventUpdate = true; 9475 update_select_options(this.$categoriesSelect, { 9476 '': this.unselectedOptionText 9477 }, this.availability.getAvailableServiceCategories(this.serviceId), this.category || ''); 9478 this.preventUpdate = false; 9479 } 9480 9481 /** 9482 * @since 1.19.0 9483 * @access protected 9484 */ 9485 renderServiceSelect() { 9486 this.preventUpdate = true; 9487 update_select_options(this.$servicesSelect, { 9488 '': this.unselectedServiceText 9489 }, this.availability.getAvailableServices(this.category, this.locationId, this.employeeId), this.serviceId || ''); 9490 this.preventUpdate = false; 9491 } 9492 9493 /** 9494 * @since 1.19.0 9495 * @access protected 9496 */ 9497 renderEmployeeSelect() { 9498 this.preventUpdate = true; 9499 update_select_options(this.$employeesSelect, { 9500 0: this.unselectedOptionText 9501 }, this.availability.getAvailableEmployees(this.serviceId, this.locationId), this.employeeId); 9502 this.preventUpdate = false;
9503 } 9504 9505 /** 9506 * @since 1.19.0 9507 * @access protected 9508 */ 9509 renderLocationSelect() { 9510 this.preventUpdate = true; 9511 update_select_options(this.$locationsSelect, { 9512 0: this.unselectedOptionText 9513 }, this.availability.getAvailableLocations(this.serviceId, this.employeeId), this.locationId); 9514 this.preventUpdate = false; 9515 } 9516 show() { 9517 this.$servicesSelect.prop('required', true); 9518 super.show(); 9519 } 9520 hide() { 9521 super.hide(); 9522 this.$servicesSelect.prop('required', false); 9523 } 9524 9525 /** 9526 * @access protected 9527 * 9528 * @since 1.0 9529 */ 9530 enable() { 9531 super.enable(); 9532 9533 // Enable all selects 9534 this.$selects.prop('disabled', false); 9535 } 9536 9537 /** 9538 * @access protected 9539 * 9540 * @since 1.0 9541 */ 9542 disable() { 9543 super.disable(); 9544 9545 // Disable all selects 9546 this.$selects.prop('disabled', true); 9547 } 9548 9549 /** 9550 * @param {Event} event 9551 * 9552 * @access protected 9553 * 9554 * @since 1.2 9555 */ 9556 submitForm(event) { 9557 // Never submit the form, but allow validation 9558 if (!this.isActive || this.isValidInput()) { 9559 event.preventDefault(); 9560 } 9561 } 9562 9563 /** 9564 * @access protected 9565 * 9566 * @since 1.0 9567 */ 9568 maybeSubmit() { 9569 let cartItem = this.cart.getActiveItem(); 9570 if (cartItem === null) { 9571 return console.error('Unable to get active cart item in StepServiceForm.maybeSubmit().'); 9572 } 9573 9574 // Save service 9575 cartItem.setService(this.availability.getService(this.serviceId, true, () => { 9576 document.dispatchEvent(new CustomEvent('mpa_view_item', { 9577 detail: { 9578 cartItem: cartItem, 9579 currencyCode: mpapp().settings().getCurrency() 9580 } 9581 })); 9582 })); 9583 cartItem.setServiceCategories(this.availability.getServiceCategories(this.serviceId)); 9584 9585 // Save employee(-s) 9586 if (this.employeeId !== 0) { 9587 cartItem.setEmployee(this.availability.getEmployee(this.employeeId)); 9588 } else { 9589 cartItem.setAvailableEmployees(this.availability.filterAvailableEmployees(this.serviceId, this.locationId, 'entities')); 9590 } 9591 9592 // Save location(-s) 9593 if (this.locationId !== 0) { 9594 cartItem.setLocation(this.availability.getLocation(this.locationId)); 9595 } else { 9596 let allowedEmployees = this.employeeId || cartItem.getAvailableEmployeeIds(); 9597 cartItem.setAvailableLocations(this.availability.filterAvailableLocations(this.serviceId, allowedEmployees, 'entities')); 9598 } 9599 } 9600 } 9601 9602 /** 9603 * @since 1.0 9604 */ 9605 class AppointmentFormShortcode { 9606 /** 9607 * @since 1.0 9608 */ 9609 constructor($element) { 9610 this.$element = $element; 9611 this.$message = this.$element.children('.mpa-message'); 9612 this.cart = new Cart(); 9613 this.steps = new BookingSteps(this.cart); 9614 this.load(); // And show() 9615 } 9616 9617 /** 9618 * @since 1.9.0 9619 * @access protected 9620 */ 9621 setupSteps() { 9622 this.steps.addStep(new StepServiceForm(this.$element.find('.mpa-booking-step-service-form'), this.cart)).addStep(new StepPeriod(this.$element.find('.mpa-booking-step-period'), this.cart)).addStep(new StepCart(this.$element.find('.mpa-booking-step-cart'), this.cart)).addStep(new StepCheckout(this.$element.find('.mpa-booking-step-checkout'), this.cart)); 9623 if (mpapp().settings().isPaymentsEnabled()) { 9624 this.steps.addStep(new StepPayment(this.$element.find('.mpa-booking-step-payment'), this.cart)); 9625 } 9626 this.steps.addStep(new StepBooking(this.$element.find('.mpa-booking-step-booking'), this.cart)); 9627 this.steps.mount(this.$element); 9628 } 9629 9630 /** 9631 * @access protected 9632 * 9633 * @since 1.0 9634 */ 9635 load() { 9636 // Create the initial cart item for cart item steps 9637 this.cart.createItem(); 9638 9639 // Check if we have any service to book 9640 let availability = new AvailabilityService(); 9641 Promise.all([availability.ready(), mpapp().settings().ready()]).finally(() => { 9642 // Setup steps only when the settings are loaded 9643 this.setupSteps(); 9644 this.show(); 9645 if (!availability.isEmpty()) { 9646 this.steps.goToNextStep(); // Show the first step 9647 } else { 9648 this.$message.html(__('Sorry, there are no services, employees or locations to book.', 'motopress-appointment')); 9649 this.$message.removeClass('mpa-hide'); 9650 } 9651 }); 9652 } 9653 9654 /** 9655 * @access protected 9656 * 9657 * @since 1.0 9658 */ 9659 show() { 9660 // Remove loading GIF 9661 this.$element.addClass('mpa-loaded'); 9662 } 9663 } 9664
9665 /** 9666 * @since 1.3 9667 */ 9668 class AppointmentFormWidget extends AppointmentFormShortcode { 9669 /** 9670 * @since 1.6.0 9671 */ 9672 constructor($element) { 9673 super($element.children('.widget-body').first()); 9674 } 9675 } 9676 9677 // Init shortcodes and widgets 9678 jQuery('.appointment-form-shortcode').each((i, element) => { 9679 new AppointmentFormShortcode(jQuery(element)); 9680 }); 9681 jQuery('.appointment-form-widget').each((i, element) => { 9682 new AppointmentFormWidget(jQuery(element)); 9683 }); 9684 9685})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.