1/* global common, product, utils, bootstrap, flatpickr */ 2 3/** 4 * @typedef {object} TicketCalendarDay 5 * @property {number} dayTypeId 6 * @property {number[]} dayTypeTimeQuantities Indexed by dayTypeTimeId 7 */ 8/** 9 * @typedef {object} TicketCalendarDayType 10 * @property {number} id 11 * @property {string} name 12 * @property {string} status 13 * @property {string} color 14 * @property {TicketCalendarDayTypeTime[]} dayTypeTimes 15 */ 16/** 17 * @typedef {object} TicketCalendarCategory 18 * @property {number} id 19 * @property {string} name 20 */ 21/** 22 * @typedef {object} TicketCalenderProduct 23 * @property {number} id 24 * @property {string} name 25 * @property {number|null} contractRuleId 26 * @property {string} contractName 27 * @property {boolean} noNegativeQuantity 28 * @property {boolean} hasInventoryPerDate 29 * @property {number} dayCount 30 * @property {boolean} hasDayPrices 31 * @property {number} amount 32 * @property {number} partnerAmount 33 * @property {TicketCalendarProductVariant[]} productVariants 34 * @property {TicketCalendarProductDayPrice[]} dayPrices 35 * @property {number[]} dayTypeTimePrices 36 * @property {number[]} dayTypeTimeGroupPrices 37 */ 38/** 39 * @typedef {object} TicketCalendarProductVariant 40 * @property {number} id 41 * @property {number} quantity 42 * @property {TicketCalendarProductVariantDate[]} dates 43 */ 44/** 45 * @typedef {object} TicketCalendarProductVariantDate 46 * @property {string} date 47 * @property {number} quantity 48 * @property {number} amount 49 * @property {boolean} hasSoldItems 50 */ 51/** 52 * @typedef {object} TicketCalendarProductPriceData 53 * @property {boolean} isSellable 54 * @property {number} amount 55 * @property {number|null} discountedAmount 56 */ 57/** 58 * @typedef {object} TicketCalendarProductDayPrice 59 * @property {number} amount 60 * @property {number} amountInclVat 61 * @property {number|null} discountedAmount 62 * @property {number|null} discountedAmountInclVat 63 * @property {number} discountDaysBefore 64 */ 65/** 66 * @typedef {object} TicketCalendarDayTypeTime 67 * @property {number} id 68 * @property {number} dayTypeId 69 * @property {string} time 70 * @property {string} toTime 71 * @property {string|null} group 72 * @property {number} defaultQuantity 73 */ 74/** 75 * @typedef {object} TicketCalendarProductDayTypePrice 76 * @property {number} dayTypeId 77 * @property {number|null} dayTypeTimeId 78 * @property {string|null} group 79 * @property {number} amount 80 */ 81/** 82 * @typedef {object} TicketCalendarProductDayTypeTimeData 83 * @property {number|null} amount 84 * @property {boolean} isSellable 85 */ 86/** 87 * @typedef {object} TicketCalendarProductDayData 88 * @property {boolean} hasProducts 89 * @property {boolean} hasSellableProducts 90 * @property {number|null} fromAmount 91 * @property {number|null} fromDiscountedAmount 92 * @property {number|null} fromPartnerAmount 93 * @property {number|null} productVariantQuantity 94 * @property {boolean} hasSoldItems 95 */ 96 97const ticketCalendar = { 98 99 SHOW_ON_DAY_NONE: 'none', 100 SHOW_ON_DAY_INFO: 'info', 101 SHOW_ON_DAY_FROM_PRICE: 'fromPrice', 102 SHOW_ON_DAY_ALL: 'all', 103 104 DAY_TYPE_STATUS_OPEN: 'open', 105 DAY_TYPE_STATUS_CLOSED: 'closed', 106 107 /** 108 * @var {TicketCalendarDay[]} 109 */ 110 openingHoursDays: {}, 111 112 /** 113 * @var {string[]} 114 */ 115 loadedOpeningHoursMonths: [], 116 117 /** 118 * @var {TicketCalendarDay[]} 119 */ 120 inventoryDays: {}, 121 122 /** 123 * @var {string[]} 124 */ 125 loadedInventoryMonths: [], 126 127 /** 128 * @var {number} 129 */ 130 monthLoadedSemaphore: 0, 131 132 /** 133 * @var {TicketCalendarDayType[]} 134 */ 135 dayTypes: {}, 136 137 /** 138 * @var {TicketCalenderProduct[]} 139 */ 140 products: {}, 141 142 /** 143 * @var {number[]} 144 */ 145 dayPricePriority: [], 146 147 /** 148 * @var {TicketCalendarCategory} 149 */ 150 category: {}, 151 152 /** 153 * @var {string|null} 154 */ 155 selector: null, 156 157 /** 158 * @var {string|null} 159 */ 160 modalContainerId: null, 161 162 /** 163 * @var {string} Either 'modal' or 'offcanvas' 164 */ 165 modalStyle: 'modal', 166 167 /** 168 * @var {string} 169 */ 170 productStyle: 'rows', 171 172 /** 173 * @var {boolean} 174 */ 175 showProductsBeforeDate: false, 176 177 /** 178 * @var {string|null} 179 */ 180 startDate: null, 181 182 /** 183 * @var {string|null} 184 */ 185 endDate: null, 186 187 /** 188 * @var {string|null} 189 */ 190 selectedDateString: null, 191 192 /** 193 * For CalendarModal only: the last date the user actually confirmed. Used to restore 194 * selectedDateString if the modal is dismissed (Esc/close/backdrop) without confirming 195 * a newly browsed date. 196 * 197 * @var {string|null} 198 */ 199 confirmedDateString: null, 200 201 /** 202 * @var {number} 203 */
204 selectedDayCount: 1, 205 206 /** 207 * @var {number|null} 208 */ 209 selectedDayTypeTimeId: null, 210 211 /** 212 * @var {string|null} 213 */ 214 selectedDayTypeTimeGroup: null, 215 216 /** 217 * @var {number|null} 218 */ 219 openingHoursCalendarId: null, 220 221 /** 222 * @var {number|null} 223 */ 224 inventoryCalendarId: null, 225 226 /** 227 * @var {string} Either 'no', 'allow_negative_quantity', or 'no_negative_quantity' 228 */ 229 dayTypeTimeState: 'no', 230 231 /** 232 * @var {string} 233 */ 234 showOnDay: this.SHOW_ON_DAY_NONE, 235 236 /** 237 * @var {string|null} 238 */ 239 calendarPriceStyle: null, 240 241 /** 242 * @var {boolean|null} 243 */ 244 calendarPriceShowOnlyIntegerDigit: null, 245 246 /** 247 * @var {boolean} 248 */ 249 onlyNonZeroFromPrice: false, 250 251 /** 252 * @var {string} 253 */ 254 createLanguageCode: '', 255 256 /** 257 * Should shown products be tracked. 258 * 259 * @var {boolean} 260 */ 261 viewProductEventTriggered: false, 262 263 /** 264 * Should quantity be shown. 265 */ 266 showQuantity: false, 267 268 /** 269 * Should low quantity indication be shown. 270 */ 271 showLowQuantity: false, 272 273 /** 274 * Threshold for low quantity indication. 275 * 276 * @var {number|null} 277 */ 278 lowQuantityThreshold: 1, 279 280 /** 281 * Should max quantity be shown. 282 */ 283 showMaxQuantity: false, 284 285 /** 286 * True if we have started creation of the Flatpickr calendar. 287 * 288 * @var {boolean} 289 */ 290 flatpickrCreated: false, 291 292 /** 293 * The created Flatpickr instance. 294 * 295 * @var {flatpickr} 296 */ 297 flatpickrInstance: null, 298 299 /** 300 * Initialize the ticket calendar component. 301 */ 302 init: function() { 303 this.selectedDateString = this.getInitialDateString(this.selectedDateString); 304 this.confirmedDateString = this.selectedDateString; 305 this.setSelectedDayCount(1); 306 this.loadMonth(new Date(this.selectedDateString || this.startDate)); 307 if (this.openingHoursCalendarId === null) { 308 this.createFlatpickrWithL10n(); 309 } 310 if (this.modalContainerId) { 311 const eventName = this.modalStyle === 'offcanvas' ? 'hidden.bs.offcanvas' : 'hidden.bs.modal'; 312 $('#' + this.modalContainerId).on(eventName, function() { 313 ticketCalendar.onDismissModal(); 314 }); 315 } 316 if (this.modalContainerId && ! this.selectedDateString) { 317 this.updateFlow(); 318 } 319 if (this.showProductsBeforeDate) { 320 $(document).on('change input', '[data-content="ticket-calendar-products-form"] input[type="number"]', function() { 321 ticketCalendar.updateAddToCartState(); 322 }); 323 } 324 }, 325 326 /** 327 * Find the initial date. 328 * 329 * @param {string} defaultDateString If no date is specified in the hash-values, then this will be used instead. 330 * @returns {string} 331 */ 332 getInitialDateString: function(defaultDateString) { 333 let selectedDateString = common.getHashValue('date'); 334 if (selectedDateString) { 335 return selectedDateString; 336 } 337 338 return defaultDateString; 339 }, 340 341 /** 342 * Return the date to use for price/availability display. 343 * When showProductsBeforeDate is on and no date is confirmed, falls back to startDate. 344 * 345 * @returns {string|null} 346 */ 347 getDisplayDate: function() { 348 return this.selectedDateString || (this.showProductsBeforeDate ? this.startDate : null); 349 }, 350 351 /** 352 * Load a Flatpickr localization, if needed, before creating the calendar. 353 */ 354 createFlatpickrWithL10n: function() { 355 if (this.flatpickrCreated) { 356 return; 357 } 358 this.flatpickrCreated = true; 359 360 // Defer flatpickr creation until the modal/offcanvas is shown, to avoid sizing artefacts. 361 if (this.modalContainerId) { 362 const eventName = this.modalStyle === 'offcanvas' ? 'shown.bs.offcanvas' : 'shown.bs.modal';
363 const containerId = this.modalContainerId; 364 $('#' + containerId).one(eventName, function() { 365 ticketCalendar.flatpickrCreated = false; 366 ticketCalendar.modalContainerId = null; // prevent re-deferral during recursive call 367 ticketCalendar.createFlatpickrWithL10n(); 368 ticketCalendar.modalContainerId = containerId; // restore for close/confirm 369 }); 370 371 return; 372 } 373 374 this.createLanguageCode = common.getLanguageCode(); // We just assume it is a supported language 375 if (flatpickr.l10ns[this.createLanguageCode] !== undefined) { 376 this.createFlatpickr(); 377 378 return; 379 } 380 381 // Load the localization before creating the calendar 382 jQuery.ajax({ 383 url: 'https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/' + this.createLanguageCode + '.js', 384 dataType: 'script', 385 success: function() { 386 ticketCalendar.createFlatpickrWhenLanguageReady(); 387 }, 388 async: true 389 }); 390 }, 391 392 /** 393 * Create the Flatpickr calendar the wanted language is ready. 394 */ 395 createFlatpickrWhenLanguageReady: function() { 396 if (flatpickr.l10ns[this.createLanguageCode] === undefined) { 397 setTimeout(function() { 398 ticketCalendar.createFlatpickrWhenLanguageReady(); 399 }, 100); 400 } 401 402 this.createFlatpickr(); 403 }, 404 405 /** 406 * Create the Flatpickr calendar. 407 */ 408 createFlatpickr: function() { 409 this.flatpickrInstance = flatpickr(this.selector, { 410 locale: this.createLanguageCode, 411 inline: true, 412 weekNumbers: false, 413 minDate: this.startDate, 414 maxDate: this.endDate, 415 defaultDate: this.selectedDateString, 416 onChange: function(selectedDates) { 417 ticketCalendar.onDayChange(selectedDates[0]); 418 }, 419 onMonthChange: function(selectedDates, dateString, instance) { 420 ticketCalendar.onMonthChanged(instance.currentYear, instance.currentMonth + 1); 421 }, 422 onYearChange: function(selectedDates, dateString, instance) { 423 ticketCalendar.onYearChanged(instance.currentYear, instance.currentMonth + 1); 424 }, 425 onDayCreate: function(selectedDates, dateString, instance, dayElement) { 426 ticketCalendar.onDayCreate(dayElement.dateObj, dayElement); 427 }, 428 onReady: function() { 429 // Remove loader 430 $(ticketCalendar.selector).closest('.loader-container').removeClass('loader'); 431 432 // Disable the input field for year 433 if (ticketCalendar.startDate.slice(0, 4) === ticketCalendar.endDate.slice(0, 4)) { 434 $('.flatpickr-current-month').find('input.numInput').prop('disabled', true); 435 } 436 437 // Reselect the current date to initialize it properly (skip when no date is pre-selected) 438 if (ticketCalendar.selectedDateString) { 439 ticketCalendar.selectDate(ticketCalendar.selectedDateString); 440 } 441 } 442 }); 443 }, 444 445 /** 446 * Redraw the flatpickr calendar. 447 * 448 * @param {boolean} [createIfMissing] 449 */ 450 redrawFlatpickr: function(createIfMissing) { 451 if (this.flatpickrInstance) { 452 this.flatpickrInstance.redraw(); 453 } 454 else if (createIfMissing) { 455 this.createFlatpickrWithL10n(); 456 } 457 }, 458 459 /** 460 * Event handler for when a day is selected in the calendar. 461 * 462 * @param {Date} selectedDate 463 */ 464 onDayChange: function(selectedDate) { 465 this.selectDate(utils.getIsoDate(selectedDate)); 466 }, 467 468 /** 469 * Event handler for when the selected month has changed in the calendar. 470 * 471 * @param {number} year 472 * @param {number} month 473 */ 474 onMonthChanged: function(year, month) { 475 this.loadMonth(null, year, month); 476 }, 477 478 /** 479 * Event handler for when the selected year has changed in the calendar. 480 * 481 * @param {number} year 482 * @param {number} month 483 */ 484 onYearChanged: function(year, month) { 485 // The month have then changed as well 486 this.loadMonth(null, year, month); 487 }, 488 489 /** 490 * Event handler for when a day is created for the calendar. 491 * 492 * @param {Date} date 493 * @param {HTMLElement} dayElement 494 */ 495 onDayCreate: function(date, dayElement) { 496 const dateString = utils.getIsoDate(date); 497 if (dateString < this.startDate || dateString > this.endDate) { 498 return; 499 } 500 501 // Append element containing background-color in order to control opacity 502 const dayElementData = this.getDayElementData(dateString); 503 $(dayElement) 504 .prop('title', dayElementData.title) 505 .addClass(dayElementData.dayClass)
506 .append('<span class="day-background"></span>') 507 .append(dayElementData.fromPriceElement); 508 }, 509 510 /** 511 * Get elements for rendering the specified date in the calendar. 512 * 513 * @param {string} dateString 514 * @returns {{title: string, dayClass: string, fromPriceElement: string}} 515 */ 516 getDayElementData: function(dateString) { // eslint-disable-line max-statements,complexity 517 let title = ''; 518 let dayClass; 519 let fromPriceElement = ''; 520 521 if (this.isDayClosed(dateString)) { 522 if ([this.SHOW_ON_DAY_ALL, this.SHOW_ON_DAY_INFO].includes(this.showOnDay)) { 523 fromPriceElement = common.getResource('ticket_calendar.day_from_price.closed'); 524 } 525 526 return { 527 title: common.getResource('ticket_calendar.day_title.closed'), 528 dayClass: 'day-closed', 529 fromPriceElement: fromPriceElement 530 }; 531 } 532 533 const productsOnDate = this.checkProductsOnDate(dateString); 534 const desiredQuantity = this.getDesiredQuantity(); 535 536 if (desiredQuantity > 0 && productsOnDate.hasSellableProducts && productsOnDate.productVariantQuantity !== null && productsOnDate.productVariantQuantity < desiredQuantity) { 537 dayClass = 'day-sold-out'; 538 if ([this.SHOW_ON_DAY_ALL, this.SHOW_ON_DAY_INFO].includes(this.showOnDay)) { 539 fromPriceElement = common.insertObjectDataInTemplate({ 540 quantity: productsOnDate.productVariantQuantity 541 }, common.getResource('ticket_calendar.day_from_price.insufficient_quantity')); 542 } 543 } 544 else if (productsOnDate.hasSellableProducts) { 545 dayClass = 'day-available'; 546 if ((productsOnDate.fromAmount !== null || productsOnDate.fromPartnerAmount !== null) && [ 547 this.SHOW_ON_DAY_ALL, 548 this.SHOW_ON_DAY_FROM_PRICE 549 ].includes(this.showOnDay)) { 550 if (productsOnDate.fromPartnerAmount !== null) { 551 fromPriceElement = common.insertObjectDataInTemplate({ 552 price: utils.formatAmount(productsOnDate.fromPartnerAmount, ticketCalendar.calendarPriceStyle, ticketCalendar.calendarPriceShowOnlyIntegerDigit) 553 }, common.getResource('ticket_calendar.day_from_price')); 554 } 555 else if (productsOnDate.fromDiscountedAmount === null) { 556 fromPriceElement = common.insertObjectDataInTemplate({ 557 price: utils.formatAmount(productsOnDate.fromAmount, ticketCalendar.calendarPriceStyle, ticketCalendar.calendarPriceShowOnlyIntegerDigit) 558 }, common.getResource('ticket_calendar.day_from_price')); 559 } 560 else { 561 fromPriceElement = common.insertObjectDataInTemplate({ 562 price: utils.formatAmount(productsOnDate.fromAmount, ticketCalendar.calendarPriceStyle, ticketCalendar.calendarPriceShowOnlyIntegerDigit), 563 discount: utils.formatAmount(productsOnDate.fromDiscountedAmount, ticketCalendar.calendarPriceStyle, ticketCalendar.calendarPriceShowOnlyIntegerDigit) 564 }, common.getResource('ticket_calendar.day_from_discount')); 565 } 566 } 567 else if ((this.showQuantity || this.showLowQuantity) && [this.SHOW_ON_DAY_ALL, this.SHOW_ON_DAY_INFO].includes(this.showOnDay) && productsOnDate.productVariantQuantity !== null) { 568 const isLowQuantity = this.showLowQuantity && this.lowQuantityThreshold >= productsOnDate.productVariantQuantity; 569 if (isLowQuantity) { 570 dayClass = 'day-low-quantity'; 571 } 572 if (this.showQuantity) { 573 if (isLowQuantity) { 574 fromPriceElement = common.insertObjectDataInTemplate({ 575 quantity: productsOnDate.productVariantQuantity, 576 maxQuantity: productsOnDate.productVariantMaxQuantity 577 }, common.getResource(this.showMaxQuantity ? 'ticket_calendar.day_available_low_quantity_max_quantity' : 'ticket_calendar.day_available_low_quantity')); 578 } 579 else { 580 fromPriceElement = common.insertObjectDataInTemplate({ 581 quantity: productsOnDate.productVariantQuantity, 582 maxQuantity: productsOnDate.productVariantMaxQuantity 583 }, common.getResource(this.showMaxQuantity ? 'ticket_calendar.day_available_max_quantity' : 'ticket_calendar.day_available')); 584 } 585 } 586 else if (isLowQuantity) { 587 fromPriceElement = common.getResource('ticket_calendar.day_available_low_quantity_text_only'); 588 } 589 } 590 } 591 else if (productsOnDate.hasProducts && productsOnDate.hasSoldItems) { 592 title = common.getResource('ticket_calendar.day_title.sold_out'); 593 dayClass = 'day-sold-out'; 594 if ([this.SHOW_ON_DAY_ALL, this.SHOW_ON_DAY_INFO].includes(this.showOnDay)) { 595 fromPriceElement = common.getResource('ticket_calendar.day_from_price.sold_out'); 596 } 597 } 598 else { 599 title = common.getResource('ticket_calendar.day_title.unavailable'); 600 dayClass = 'day-unavailable'; 601 if ([this.SHOW_ON_DAY_ALL, this.SHOW_ON_DAY_INFO].includes(this.showOnDay)) { 602 fromPriceElement = common.getResource('ticket_calendar.day_from_price.unavailable'); 603 } 604 } 605 606 return { 607 title: title, 608 dayClass: dayClass, 609 fromPriceElement: fromPriceElement 610 }; 611 }, 612 613 /** 614 * Check all products for availability. 615 * 616 * @param {string} dateString 617 * @returns {TicketCalendarProductDayData} 618 */ 619 checkProductsOnDate: function(dateString) { // eslint-disable-line max-statements,complexity 620 let hasProducts = false;
621 let hasSellableProducts = false; 622 let fromAmount = null; 623 let fromDiscountedAmount = null; 624 let fromPartnerAmount = null; 625 let productVariantQuantity = null; 626 let productVariantMaxQuantity = null; 627 let hasSoldItems = false; 628 let pricePriorityIndex = null; 629 630 const daysBefore = this.getDaysBefore(dateString); 631 632 for (let productId in this.products) { 633 if ( ! this.products.hasOwnProperty(productId)) { 634 continue; 635 } 636 const product = this.products[productId]; 637 hasProducts = true; 638 let isSellable = true; 639 let amount = null; 640 if ( ! product.hasDayPrices) { 641 amount = product.amount; 642 } 643 if (product.partnerAmount !== null && (fromPartnerAmount === null || product.partnerAmount < fromPartnerAmount)) { 644 fromPartnerAmount = product.partnerAmount; 645 } 646 647 if ( (product.fromDate !== undefined && product.fromDate !== false && product.fromDate > dateString) 648 && (product.toDate !== undefined && product.toDate !== false && product.toDate < dateString)) { 649 // Not buyable for this period 650 isSellable = false; 651 } 652 else if (product.hasDayPrices && product.dayPrices[dateString] === undefined) { 653 // Has day prices, but no price for this day 654 isSellable = false; 655 } 656 657 if (isSellable && product.noNegativeQuantity) { 658 // Check stock 659 isSellable = false; 660 for (let productVariantId in product.productVariants) { 661 if ( ! product.productVariants.hasOwnProperty(productVariantId)) { 662 continue; 663 } 664 let productVariant = product.productVariants[productVariantId]; 665 if (product.hasInventoryPerDate) { 666 let productVariantDate = productVariant.dates[dateString]; 667 if (productVariantDate === undefined) { 668 if (productVariant.quantity > 0) { // eslint-disable-line max-depth 669 isSellable = true; 670 productVariantQuantity = productVariant.quantity; 671 } 672 productVariantMaxQuantity = productVariant.maxQuantity; 673 } 674 else { 675 if (productVariantDate.quantity !== undefined && productVariantDate.quantity > 0) { // eslint-disable-line max-depth 676 isSellable = true; 677 productVariantQuantity = productVariantDate.quantity; 678 } 679 productVariantMaxQuantity = productVariantDate.maxQuantity; 680 if (productVariantDate.amount !== undefined && productVariantDate.amount !== null 681 && (amount === null || productVariantDate.amount < amount)) { // eslint-disable-line max-depth 682 amount = productVariantDate.amount; 683 } 684 685 if (productVariantDate.hasSoldItems) { 686 hasSoldItems = true; 687 } 688 } 689 } 690 else if (productVariant.quantity > 0) { 691 isSellable = true; 692 productVariantQuantity = productVariant.quantity; 693 productVariantMaxQuantity = productVariant.maxQuantity; 694 } 695 } 696 } 697 698 if (isSellable && this.dayTypeTimeState === 'no_negative_quantity') { 699 const day = this.inventoryDays[dateString]; 700 if (day !== undefined) { 701 // A calendar is in use, so we assume the day type is used for closed days, and therefore all other days must "have sold items" to prevent them from showing as closed 702 hasSoldItems = true; 703 const today = new Date(); 704 const isToday = utils.getIsoDate(today) === dateString; 705 const time = utils.getIsoTime(today, true); 706 let quantity = 0; 707 let hasFutureTime = false; 708 const dayType = this.dayTypes[day.dayTypeId]; 709 for (let dayTypeTimeId in dayType.dayTypeTimes) { 710 if (day.dayTypeTimeQuantities[dayTypeTimeId] !== undefined) { 711 quantity += day.dayTypeTimeQuantities[dayTypeTimeId]; 712 } 713 else if (dayType.dayTypeTimes[dayTypeTimeId] !== undefined) { 714 quantity += dayType.dayTypeTimes[dayTypeTimeId].defaultQuantity; 715 } 716 if (isToday && dayType.dayTypeTimes[dayTypeTimeId].time < time) { 717 hasFutureTime = true; 718 } 719 } 720 isSellable = quantity > 0 && ( ! isToday || (isToday && hasFutureTime)); 721 } 722 } 723 724 if (isSellable) { 725 hasSellableProducts = true; 726 727 if (amount !== null 728 && ( ! this.onlyNonZeroFromPrice || amount > 0) 729 && (fromAmount === null || amount < fromAmount)) { 730 fromAmount = amount; 731 } 732 733 if (product.hasDayPrices && product.dayPrices[dateString] !== undefined) { 734 // From price should follow the day price priority 735 let priorityIndex = this.dayPricePriority.indexOf(parseInt(productId, 10)); 736 if (priorityIndex === -1) { 737 priorityIndex = Number.MAX_SAFE_INTEGER; 738 } 739 if ((pricePriorityIndex === null || priorityIndex < pricePriorityIndex) 740 && product.dayPrices[dateString].amount !== null) { 741 fromAmount = product.dayPrices[dateString].amountInclVat; // Always overwrite price 742 if (product.dayPrices[dateString].discountDaysBefore !== null 743 && daysBefore >= product.dayPrices[dateString].discountDaysBefore) { 744 fromDiscountedAmount = product.dayPrices[dateString].discountedAmountInclVat; 745 } 746 else { 747 fromDiscountedAmount = null; 748 } 749 pricePriorityIndex = priorityIndex; 750 } 751 } 752 } 753 } 754 755 return { 756 hasProducts: hasProducts, 757 hasSellableProducts: hasSellableProducts, 758 fromAmount: fromAmount, 759 fromDiscountedAmount: fromDiscountedAmount, 760 fromPartnerAmount: fromPartnerAmount, 761 productVariantQuantity: productVariantQuantity, 762 productVariantMaxQuantity: productVariantMaxQuantity, 763 hasSoldItems: hasSoldItems 764 }; 765 }, 766 767 /** 768 * Check if an opening hours calendar or inventory days calendar says the specified date is closed. 769 * 770 * @param {string} dateString 771 * @returns {boolean} 772 */ 773 isDayClosed: function(dateString) { 774 // Check opening hours calendar 775 if (this.openingHoursCalendarId) { 776 return this.openingHoursDays[dateString] === undefined 777 || this.openingHoursDays[dateString].dayTypeId === null 778 || this.dayTypes[this.openingHoursDays[dateString].dayTypeId] === undefined 779 || this.dayTypes[this.openingHoursDays[dateString].dayTypeId].status === this.DAY_TYPE_STATUS_CLOSED; 780 } 781 782 // Then check inventory calendar 783 if (this.inventoryCalendarId) { 784 return this.inventoryDays[dateString] === undefined 785 || this.inventoryDays[dateString].dayTypeId === null 786 || this.dayTypes[this.inventoryDays[dateString].dayTypeId] === undefined 787 || this.dayTypes[this.inventoryDays[dateString].dayTypeId].status === this.DAY_TYPE_STATUS_CLOSED; 788 } 789 790 // No calendar has any opinion, so we treat it as an open day 791 return false;
792 }, 793 794 /** 795 * Check whether the specified date can actually be booked, i.e. it is open and has sellable products left. 796 * 797 * @param {string} dateString 798 * @returns {boolean} 799 */ 800 isDayBookable: function(dateString) { 801 return ! this.isDayClosed(dateString) && this.checkProductsOnDate(dateString).hasSellableProducts; 802 }, 803 804 /** 805 * Set the specified date as selected. 806 * Update rendering. 807 * 808 * For CalendarModal, browsing dates only updates the modal's own confirm button (active 809 * with the date when bookable, inactive with the closed/sold-out/unavailable status when 810 * not) â the product/quantity form, total and add-to-cart live on the main page behind the 811 * modal and must not change until the user actually clicks Confirm (see onConfirmModalDate()), 812 * since they can still cancel via Esc/close without confirming anything; confirmedDateString/ 813 * onDismissModal() restore the last confirmed date in that case. The plain (non-modal) 814 * Calendar has no such confirm step, so it keeps updating the whole flow immediately. 815 * 816 * @param {string} dateString 817 */ 818 selectDate: function(dateString) { 819 this.selectedDateString = dateString; 820 this.updateFlowOrDeferToModal(); 821 }, 822 823 /**
824 * Update the modal's confirm button only, deferring the rest of updateFlow() (product/quantity 825 * form, total, add-to-cart) until Confirm is actually clicked. The plain (non-modal) Calendar 826 * has no confirm step, so it always runs the full flow immediately. 827 */ 828 updateFlowOrDeferToModal: function() { 829 if (this.modalContainerId) { 830 this.updateModalConfirmButton(); 831 } 832 else { 833 this.applyQuantityCoverageReset(this.selectedDateString); 834 this.updateFlow(); 835 } 836 }, 837 838 /** 839 * Sum the quantities currently entered across all products in the form. 840 * 841 * @returns {number} 842 */ 843 getDesiredQuantity: function() { 844 let total = 0; 845 $('[data-content="ticket-calendar-products-form"] input[type="number"]').each(function() { 846 total += parseInt($(this).val(), 10) || 0; 847 }); 848 849 return total; 850 }, 851 852 /** 853 * Reset all entered product quantities back to zero. 854 */ 855 resetProductQuantities: function() { 856 $('[data-content="ticket-calendar-products-form"] [name="quantity"]').each(function() { 857 $(this).val(0); 858 }); 859 }, 860 861 /** 862 * If the given date can't cover a quantity the user already entered, reset it back to zero 863 * so they have to reselect a quantity the date can actually fulfil. No-op if nothing was 864 * entered, or if the date already covers it. 865 * 866 * @param {string} dateString 867 */ 868 applyQuantityCoverageReset: function(dateString) { 869 const desiredQuantity = this.getDesiredQuantity(); 870 if (desiredQuantity <= 0) { 871 return; 872 } 873 const productsOnDate = this.checkProductsOnDate(dateString); 874 if (productsOnDate.productVariantQuantity === null || productsOnDate.productVariantQuantity < desiredQuantity) { 875 this.resetProductQuantities(); 876 } 877 }, 878 879 /** 880 * Handler for when the day count has changed. 881 * 882 * @param {number} count 883 */ 884 selectDayCount: function(count) { 885 this.setSelectedDayCount(count); 886 this.updateFlow(); 887 }, 888 889 /** 890 * Set the selected day count and its tab. 891 * 892 * @param {number} count 893 */ 894 setSelectedDayCount: function(count) { 895 this.selectedDayCount = count; 896 $('[data-day_tab]').removeClass('active') 897 .filter('[data-day_tab="' + count + '"]').addClass('active'); 898 }, 899 900 /** 901 * Update the whole flow rendering. 902 */ 903 updateFlow: function() { 904 this.updateDate(); 905 this.updateTimeslots(); 906 this.updateProducts(); 907 this.updateTotalPrice(); 908 this.updateModalConfirmButton(); 909 this.updateAddToCartState(); 910 }, 911 912 /** 913 * Update rendering of date-specific elements. 914 */ 915 updateDate: function() { // eslint-disable-line max-statements 916 if ( ! this.selectedDateString) { 917 $('[data-content="selected-date"]').html(''); 918 $('[data-content="ticket-products-closed"]').hide(); 919 $('[data-content="ticket-products-unavailable"]').hide(); 920 $('[data-content="ticket-products-sold-out"]').hide(); 921 $('[data-content="ticket-calendar-timeslots"]').hide(); 922 $('[data-content="ticket-calendar-products-form"]').toggle(this.showProductsBeforeDate || this.productStyle === 'radio'); 923 $('[data-content="ticket-calendar-total"]').toggle(this.showProductsBeforeDate); 924 $('[data-content="ticket-calendar-add-to-cart"]').toggle(this.showProductsBeforeDate); 925 926 return; 927 } 928 929 let showUnavailable = false; 930 let showSoldOut = false; 931 let showClosed = false; 932 933 if (this.isDayClosed(this.selectedDateString)) { 934 showClosed = true; 935 } 936 else { 937 const productsOnDate = this.checkProductsOnDate(this.selectedDateString); 938 if ( ! productsOnDate.hasProducts) { 939 showUnavailable = true; 940 } 941 else if ( ! productsOnDate.hasSellableProducts) { 942 showSoldOut = true; 943 } 944 } 945 946 $('[data-content="selected-date"]').html(utils.formatDate(this.selectedDateString)); 947 948 if (showClosed) { 949 $('[data-content="ticket-products-closed"]').show(); 950 } 951 else { 952 $('[data-content="ticket-products-closed"]').hide(); 953 } 954 if (showUnavailable) { 955 $('[data-content="ticket-products-unavailable"]').show(); 956 } 957 else { 958 $('[data-content="ticket-products-unavailable"]').hide(); 959 } 960 if (showSoldOut) { 961 $('[data-content="ticket-products-sold-out"]').show(); 962 } 963 else { 964 $('[data-content="ticket-products-sold-out"]').hide(); 965 } 966 if (showUnavailable || showSoldOut || showClosed) { 967 $('[data-content="ticket-calendar-timeslots"]').hide(); 968 $('[data-content="ticket-calendar-products-form"]').hide(); 969 $('[data-content="ticket-calendar-total"]').hide();
970 $('[data-content="ticket-calendar-add-to-cart"]').hide(); 971 } 972 else { 973 $('[data-content="ticket-calendar-timeslots"]').show(); 974 $('[data-content="ticket-calendar-products-form"]').show(); 975 $('[data-content="ticket-calendar-total"]').show(); 976 $('[data-content="ticket-calendar-add-to-cart"]').show(); 977 } 978 }, 979 980 /** 981 * Update rendering of timeslot elements. 982 */ 983 updateTimeslots: function() { // eslint-disable-line max-statements 984 if (this.dayTypeTimeState === 'no') { 985 return; 986 } 987 988 if (this.inventoryDays[this.selectedDateString] === undefined 989 || this.inventoryDays[this.selectedDateString].dayTypeId === null 990 || this.dayTypes[this.inventoryDays[this.selectedDateString].dayTypeId] === undefined) { 991 return; 992 } 993 const dayType = this.dayTypes[this.inventoryDays[this.selectedDateString].dayTypeId]; 994 995 let dayTypeTimeElements = $('[data-content="day_type_time"]'); 996 if (dayTypeTimeElements.length === 0) { 997 return; 998 } 999 let dayTypeTimeParent = null; 1000 const elementType = dayTypeTimeElements.get(0).nodeName; 1001 switch (elementType) { 1002 case 'SELECT': 1003 dayTypeTimeParent = dayTypeTimeElements; 1004 dayTypeTimeElements = dayTypeTimeElements.find('option'); 1005 break; 1006 case 'INPUT': 1007 default: 1008 break; 1009 } 1010 const today = new Date(); 1011 const isToday = utils.getIsoDate(today) === this.selectedDateString; 1012 const time = utils.getIsoTime(today, true); 1013 dayTypeTimeElements.each(function() { 1014 ticketCalendar.updateTimeslot($(this), dayType, isToday, time); 1015 }); 1016 1017 // Select the first one, except if one of the shown is already selected 1018 const shownDayTypeTimeElements = elementType === 'SELECT' ? dayTypeTimeElements.filter(':not(:disabled)') : dayTypeTimeElements.filter(':visible'); 1019 if (shownDayTypeTimeElements.length > 0) { 1020 if (dayTypeTimeParent) { 1021 dayTypeTimeParent.show(); 1022 } 1023 if (elementType === 'SELECT') { 1024 const selectedDayTypeTimeElements = shownDayTypeTimeElements.filter(':selected'); 1025 if (selectedDayTypeTimeElements.length === 0) { 1026 const $firstOption = shownDayTypeTimeElements.first(); 1027 $firstOption.prop('selected', true); 1028 } 1029 this.setSelectedTimeslotFromElement(dayTypeTimeParent); 1030 } 1031 else if (elementType === 'INPUT') { 1032 const selectedDayTypeTimeElements = shownDayTypeTimeElements.filter(':checked'); 1033 if (selectedDayTypeTimeElements.length === 0) { 1034 const $firstInput = shownDayTypeTimeElements.first(); 1035 $firstInput.prop('checked', true); 1036 this.setSelectedTimeslotFromElement($firstInput); 1037 } 1038 else { 1039 this.setSelectedTimeslotFromElement(selectedDayTypeTimeElements.first()); 1040 } 1041 } 1042 else { 1043 const selectedDayTypeTimeElements = shownDayTypeTimeElements.filter(':checked'); 1044 if (selectedDayTypeTimeElements.length === 0) { 1045 const $firstInput = shownDayTypeTimeElements.first().find('input'); 1046 $firstInput.prop('checked', true); 1047 this.setSelectedTimeslotFromElement($firstInput); 1048 } 1049 else { 1050 this.setSelectedTimeslotFromElement(selectedDayTypeTimeElements.first().find('input')); 1051 } 1052 } 1053 } 1054 else if (dayTypeTimeParent) { 1055 dayTypeTimeParent.hide(); 1056 } 1057 }, 1058 1059 /** 1060 * Update rendering of the specified timeslot. 1061 * 1062 * @param {JQuery} $element 1063 * @param {TicketCalendarDayType} dayType 1064 * @param {boolean} isToday 1065 * @param {string} time 1066 */ 1067 updateTimeslot: function($element, dayType, isToday, time) { 1068 let isSellable = dayType.status === 'open'; 1069 1070 const dayTypeId = $element.data('day_type_id'); 1071 if (dayTypeId === dayType.id) { 1072 const dayTypeTimeId = $element.data('day_type_time_id'); 1073 if (dayType.dayTypeTimes[dayTypeTimeId] === undefined) { 1074 isSellable = false; 1075 } 1076 else if (isToday && dayType.dayTypeTimes[dayTypeTimeId].time < time) { 1077 isSellable = false; 1078 } 1079 } 1080 else { 1081 isSellable = false; 1082 } 1083 1084 if (['OPTION', 'INPUT'].includes($element.get(0).nodeName)) { 1085 $element.prop('disabled', ! isSellable); 1086 } 1087 else { 1088 $element.find('input,select').prop('disabled', ! isSellable); 1089 } 1090 if (['OPTION'].includes($element.get(0).nodeName)) { 1091 if (isSellable) { 1092 $element.show(); 1093 } 1094 else { 1095 $element.prop('selected', false); 1096 $element.hide(); 1097 } 1098 } 1099 else if (isSellable) { 1100 $element.show(); 1101 } 1102 else { 1103 $element.prop('checked', false); 1104 $element.hide(); 1105 } 1106 }, 1107 1108 /** 1109 * Update rendering of the product list. 1110 */ 1111 updateProducts: function() { 1112 const shownProducts = []; 1113 for (let productId in this.products) { 1114 if ( ! this.products.hasOwnProperty(productId)) { 1115 continue; 1116 } 1117 const product = this.products[productId]; 1118 if (this.updateProduct(product)) { 1119 shownProducts.push(product); 1120 } 1121 } 1122 1123 $('[data-content="selected_inventory_date"]').val(this.selectedDateString); 1124 $('[data-content="selected_calendar_date"]').val(this.selectedDateString); 1125 1126 this.triggerViewProductEvent(shownProducts); 1127 }, 1128 1129 /** 1130 * Trigger the viewProduct and viewProductList events with the specified products. 1131 * 1132 * @param {TicketCalenderProduct[]} products 1133 */ 1134 triggerViewProductEvent: function(products) { 1135 if (this.viewProductEventTriggered) { 1136 // We only want to trigger the event once - for the initial date 1137 return; 1138 } 1139 1140 const viewedProducts = []; 1141 for (let product of products) { 1142 let id = product.id; 1143 let name = product.name; 1144 if (product.contractRuleId) { 1145 id += '-' + product.contractRuleId; 1146 name += ' (' + product.contractName + ')'; 1147 } 1148 viewedProducts.push({ 1149 id: id, 1150 name: name, 1151 categoryName: this.category.name, 1152 price: product.amount / 100000 1153 }); 1154 } 1155 1156 $(document).trigger('vm:viewProductList', [viewedProducts]); 1157 $(document).trigger('vm:viewProduct', [viewedProducts]); 1158 1159 this.viewProductEventTriggered = true; 1160 }, 1161 1162 /** 1163 * Update rendering of the specified product. 1164 * 1165 * @param {TicketCalenderProduct} product 1166 * @returns {boolean} 1167 */ 1168 updateProduct: function(product) { // eslint-disable-line max-statements 1169 const $product = $('[data-product-id="' + product.id + '"][data-content="product"]'); 1170 const $productVariant = $product.find('[name="product_variant_id"]'); 1171 let selectedProductVariantId = null; 1172 if ($productVariant.length > 0) { 1173 if (['select-one', 'select-many'].includes($productVariant.get(0).type)) { 1174 selectedProductVariantId = $productVariant.find('option:selected').val(); 1175 } 1176 else { 1177 selectedProductVariantId = $productVariant.val(); 1178 } 1179 } 1180 1181 const priceData = this.updateProductPrice($product, product, selectedProductVariantId); 1182 1183 if (product.partnerAmount !== null) { 1184 $product.find('[data-content="price"]').html(utils.formatAmou
1184nt(product.partnerAmount)); 1185 if (priceData.discountedAmount === null) { 1186 $product.find('[data-content="normal-price-container"]').html(utils.formatAmount(priceData.amount)); 1187 } 1188 else { 1189 $product.find('[data-content="normal-price-container"]').html(utils.formatAmount(priceData.discountedAmount)); 1190 } 1191 } 1192 else if (priceData.discountedAmount === null) { 1193 $product.data('amount', priceData.amount); 1194 $product.find('[data-content="price"]').html(utils.formatAmount(priceData.amount)); 1195 $product.find('[data-content="normal-price-container"]').show(); 1196 $product.find('[data-content="original-price-container"]').hide(); 1197 $product.find('[data-content="discounted-price-container"]').hide(); 1198 } 1199 else { 1200 $product.data('amount', priceData.discountedAmount); 1201 $product.find('[data-content="price"]').html(utils.formatAmount(priceData.amount)); 1202 $product.find('[data-content="discounted-price"]').html(utils.formatAmount(priceData.discountedAmount)); 1203 $product.find('[data-content="normal-price-container"]').hide(); 1204 $product.find('[data-content="original-price-container"]').show(); 1205 $product.find('[data-content="discounted-price-container"]').show(); 1206 } 1207 1208 1209 $product.find('input,select').prop('disabled', ! priceData.isSellable); 1210 if (this.productStyle === 'radio') { 1211 $product.show(); 1212 1213 return true; 1214 } 1215 if (priceData.isSellable) { 1216 $product.show(); 1217 } 1218 else { 1219 $product.hide(); 1220 } 1221 1222 return priceData.isSellable; 1223 }, 1224 1225 /** 1226 * Figure out if the product is sellable and update its price rendering. 1227 * 1228 * @param {JQuery} $product 1229 * @param {TicketCalenderProduct} product 1230 * @param {number|null} selectedProductVariantId 1231 * @returns {TicketCalendarProductPriceData} 1232 */ 1233 updateProductPrice: function($product, product, selectedProductVariantId) { // eslint-disable-line max-statements,complexity 1234 if (product.dayCount !== undefined && product.dayCount !== this.selectedDayCount) { 1235 return { 1236 isSellable: false, 1237 amount: 0, 1238 discountedAmount: null 1239 }; 1240 } 1241 1242 const dayTypeTimeData = this.getDayTypeTimeDataForProduct(product); 1243 1244 let isSellable = ! product.noNegativeQuantity; 1245 let amount = product.amount; 1246 let discountedAmount = null; 1247 for (let productVariantId in product.productVariants) { 1248 if ( ! product.productVariants.hasOwnProperty(productVariantId)) { 1249 continue; 1250 } 1251 if (selectedProductVariantId !== null && productVariantId !== selectedProductVariantId) { 1252 continue; 1253 } 1254 let productVariant = product.productVariants[productVariantId]; 1255 if (this.dayTypeTimeState !== 'no') { 1256 isSellable = dayTypeTimeData.isSellable; 1257 if (dayTypeTimeData.amount !== null) { 1258 amount = dayTypeTimeData.amount; 1259 } 1260 } 1261 else if (product.hasInventoryPerDate) { 1262 let productVariantDate = productVariant.dates[this.getDisplayDate()]; 1263 if (productVariantDate === undefined) { 1264 if (productVariant.quantity > 0) { 1265 isSellable = true; 1266 } 1267 } 1268 else { 1269 if (productVariantDate.quantity !== undefined && productVariantDate.quantity > 0) { 1270 isSellable = true; 1271 } 1272 if (productVariantDate.amount !== undefined && productVariantDate.amount !== null && productVariantDate.amount < amount) { 1273 amount = productVariantDate.amount; 1274 } 1275 } 1276 } 1277 else if (productVariant.quantity > 0) { 1278 isSellable = true; 1279 } 1280 } 1281 1282 if (isSellable && product.hasDayPrices) { 1283 const dayPriceData = product.dayPrices[this.getDisplayDate()]; 1284 if (dayPriceData === undefined) { 1285 isSellable = false; 1286 } 1287 else { 1288 amount = dayPriceData.amountInclVat; 1289 if (dayPriceData.discountDaysBefore !== null) { 1290 const daysBefore = this.getDaysBefore(this.getDisplayDate()); 1291 if (daysBefore >= dayPriceData.discountDaysBefore) { 1292 discountedAmount = dayPriceData.discountedAmountInclVat; 1293 } 1294 } 1295 } 1296 } 1297 1298 return { 1299 isSellable: isSellable, 1300 amount: amount, 1301 discountedAmount: discountedAmount 1302 }; 1303 }, 1304 1305 /** 1306 * Get data related to the selected day type time and specified product. 1307 * 1308 * @param {TicketCalenderProduct} product 1309 * @returns {TicketCalendarProductDayTypeTimeData} 1310 */ 1311 getDayTypeTimeDataForProduct: function(product) { 1312 const result = { 1313 amount: null, 1314 isSellable: true 1315 }; 1316 if (this.dayTypeTimeState === 'no') { 1317 return result; 1318 } 1319 1320 if (this.dayTypeTimeState === 'no_negative_quantity') { 1321 const day = this.inventoryDays[this.getDisplayDate()]; 1322 let quantity; 1323 if (day !== undefined && this.selectedDayTypeTimeId !== null) { 1324 const dayType = this.dayTypes[day.dayTypeId]; 1325 if (day.dayTypeTimeQuantities[this.selectedDayTypeTimeId] !== undefined) { 1326 quantity = day.dayTypeTimeQuantities[this.selectedDayTypeTimeId]; 1327 } 1328 else if (dayType !== undefined 1329 && dayType.dayTypeTimes[this.selectedDayTypeTimeId] !== undefined) { 1330 quantity = dayType.dayTypeTimes[this.selectedDayTypeTimeId].defaultQuantity; 1331 } 1332 } 1333 result.isSellable = quantity > 0; 1334 } 1335 if (this.selectedDayTypeTimeId) {
1336 if (product.dayTypeTimePrices[this.selectedDayTypeTimeId] !== undefined) { 1337 result.amount = product.dayTypeTimePrices[this.selectedDayTypeTimeId]; 1338 } 1339 else if (product.dayTypeTimeGroupPrices[this.selectedDayTypeTimeGroup] !== undefined) { 1340 result.amount = product.dayTypeTimeGroupPrices[this.selectedDayTypeTimeGroup]; 1341 } 1342 } 1343 1344 return result; 1345 }, 1346 1347 /** 1348 * Calculate how many days before the specified we currently are. 1349 * 1350 * @param {string} dateString 1351 * @returns {number} 1352 */ 1353 getDaysBefore: function(dateString) { 1354 const now = new Date(); 1355 const selectedDate = new Date(dateString); 1356 const nowUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); 1357 const selectedDateUtc = Date.UTC(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate()); 1358 1359 return Math.floor((selectedDateUtc - nowUtc) / (1000 * 60 * 60 * 24)); 1360 }, 1361 1362 /** 1363 * Update the total price based on the currently chosen products. 1364 */ 1365 updateTotalPrice: function() { 1366 let amount = 0; 1367 let count = 0; 1368 1369 $('[data-content="ticket-calendar-products-form"] [data-product-id]:visible').each(function() { 1370 const $product = $(this); 1371 const $input = $product.find('[name="quantity"]'); 1372 const quantity = parseInt($input.val(), 10); 1373 if ( ! isNaN(quantity) && quantity > 0) { 1374 count += quantity; 1375 amount += $product.data('amount') * quantity; 1376 } 1377 }); 1378 1379 $('[data-content="total-price"]').html(utils.formatAmount(amount)); 1380 $('[data-content="total-count"]').html(count); 1381 $('[data-content="ticket-calendar-add-to-cart"]').prop('disabled', count <= 0); 1382 }, 1383 1384 /** 1385 * Load calendar days data for the specified month. 1386 * Will only load if a calendar is specified. 1387 * 1388 * @param {Date|null} date 1389 * @param {number} [year] 1390 * @param {number} [month] 1391 * @param {boolean} [force] 1392 */ 1393 loadMonth: function(date, year, month, force) { 1394 if (date) { 1395 year = date.getFullYear(); 1396 month = date.getMonth() + 1; 1397 } 1398 this.monthLoadedSemaphore = 0; 1399 this.loadMonthOpeningHours(year, month, force); 1400 this.loadMonthInventory(year, month, force); 1401 if (this.monthLoadedSemaphore === 0) { 1402 // No calendar data to load, so just create the flatpickr right away 1403 this.createFlatpickrWithL10n(); 1404 } 1405 }, 1406 1407 /** 1408 * Load opening hours calendar days data for the specified month. 1409 * 1410 * @param {number} year 1411 * @param {number} month 1412 * @param {boolean} [force] 1413 */ 1414 loadMonthOpeningHours: function(year, month, force) { 1415 if (this.openingHoursCalendarId === null) { 1416 return; 1417 } 1418 const yearMonth = year + '-' + month.toString().padStart(2, '0'); 1419 if ( ! force && this.loadedOpeningHoursMonths.indexOf(yearMonth) >= 0) { 1420 return; 1421 } 1422 1423 this.monthLoadedSemaphore++; 1424 common.ajaxLoader({ 1425 url: '/calendar/' + this.openingHoursCalendarId + '/days/' + year + '/' + month, 1426 success: function(response) { 1427 for (let i in response.data.days) { 1428 const day = response.data.days[i]; 1429 ticketCalendar.openingHoursDays[day.date] = day; 1430 } 1431 ticketCalendar.loadedOpeningHoursMonths.push(yearMonth); 1432 ticketCalendar.monthLoaded(year, month); 1433 } 1434 }); 1435 }, 1436 1437 /** 1438 * Load inventory calendar days data for the specified month. 1439 * 1440 * @param {number} year 1441 * @param {number} month 1442 * @param {boolean} [force] 1443 */ 1444 loadMonthInventory: function(year, month, force) { 1445 if (this.inventoryCalendarId === null) { 1446 return; 1447 } 1448 const yearMonth = year + '-' + month.toString().padStart(2, '0'); 1449 if ( ! force && this.loadedInventoryMonths.indexOf(yearMonth) >= 0) { 1450 return; 1451 } 1452 1453 this.monthLoadedSemaphore++; 1454 common.ajaxLoader({ 1455 url: '/calendar/' + this.inventoryCalendarId + '/days/' + year + '/' + month, 1456 success: function(response) { 1457 for (let i in response.data.days) { 1458 const day = response.data.days[i]; 1459 ticketCalendar.inventoryDays[day.date] = day; 1460 } 1461 ticketCalendar.loadedInventoryMonths.push(yearMonth); 1462 ticketCalendar.monthLoaded(year, month); 1463 } 1464 }); 1465 }, 1466 1467 /** 1468 * Trigger updates after the specified month has been loaded. 1469 * 1470 * @param {number} year 1471 * @param {number} month 1472 */ 1473 monthLoaded: function(year, month) { 1474 this.monthLoadedSemaphore--; 1475 if (this.monthLoadedSemaphore > 0) { 1476 return; 1477 } 1478 1479 this.redrawFlatpickr(true); 1480 1481 const yearMonth = year + '-' + String(month).padStart(2, '0'); 1482 if (this.selectedDateString.substring(0, 7) === yearMonth) { 1483 this.updateFlowOrDeferToModal(); 1484 } 1485 }, 1486 1487 /** 1488 * Event handler for when a timeslot has been selected. 1489 * 1490 * @param {HTMLElement} element 1491 */ 1492 onChangeTimeslot: function(element) { 1493 const $element = $(element); 1494 1495 this.setSelectedTimeslotFromElement($element); 1496 1497 this.updateProducts(); 1498 this.updateTotalPrice(); 1499 }, 1500 1501 /** 1502 * Set the selected day type time ID and group from the specified element. 1503 * 1504 * @param {JQuery} $element 1505 */ 1506 setSelectedTimeslotFromElement: function($element) { 1507 let dayTypeTimeId; 1508 if ($element.get(0).type === 'select-one') { 1509 dayTypeTimeId = $element.find('option:selected').val(); 1510 } 1511 else { 1512 dayTypeTimeId = $element.val(); 1513 } 1514 const $parent = $element.closest('[data-content="day_type_time"]'); 1515 this.selectedDayTypeTimeId = dayTypeTimeId; 1516 this.selectedDayTypeTimeGroup = $parent.data('group'); 1517 1518 $('[data-content="selected_calendar_day_type_time_id"]').val(dayTypeTimeId); 1519 }, 1520 1521 /** 1522 * Event handler for when the quantity of a product has changed. 1523 */ 1524 onChangeQuantity: function() { 1525 this.updateTotalPrice(); 1526 }, 1527 1528 /** 1529 * Submit the product list form. 1530 * 1531 * @param {boolean} ignoreMiniCart Do not update or show the mini cart after adding to cart 1532 */ 1533 submitForm: function(ignoreMiniCart = false) { 1534 const $form = $('[data-content="ticket-calendar-products-form"]'); 1535 1536 if (ignoreMiniCart) { 1537 $form.data('ignoreMiniCart', true); 1538 } 1539 1540 $form.trigger('submit'); 1541 $(document).on('vm:afterAddToCart', function() { 1542 $form.find('[name="quantity"]').each(function() { 1543 $(this).val(0); 1544 }); 1545 1546 // Resetting the button 1547 ticketCalendar.onChangeQuantity($form.find('[name="quantity"]').first()); 1548 }); 1549 }, 1550 1551 /** 1552 * Add tickets to cart on the specified date. 1553 * 1554 * @param {HTMLFormElement} form 1555 * @param {boolean} [reload] 1556 */ 1557 addToCart: function(form, reload) {
1558 if (reload) { 1559 product.addToCart.submitMultiple($(form), function() { 1560 window.location.reload(); 1561 }, true); 1562 } 1563 else { 1564 product.addToCart.submitMultiple($(form)); 1565 } 1566 }, 1567 1568 /** 1569 * Update the modal confirm button state and label based on the currently selected date. 1570 * No-op when CalendarModal is not in use (element is absent from the DOM). 1571 */ 1572 updateModalConfirmButton: function() { // eslint-disable-line max-statements 1573 const $btn = $('[data-content="ticket-calendar-modal-confirm"]'); 1574 if ($btn.length === 0) { 1575 return; 1576 } 1577 if ( ! this.selectedDateString) { 1578 $btn.text($btn.data('no-date-label') || '') 1579 .prop('disabled', true); 1580 1581 return; 1582 } 1583 if (this.isDayClosed(this.selectedDateString)) { 1584 $btn.text(common.getResource('ticket_calendar.day_title.closed')) 1585 .prop('disabled', true); 1586 1587 return; 1588 } 1589 const productsOnDate = this.checkProductsOnDate(this.selectedDateString); 1590 if ( ! productsOnDate.hasSellableProducts) { 1591 $btn.text(common.getResource(productsOnDate.hasProducts ? 'ticket_calendar.day_title.sold_out' : 'ticket_calendar.day_title.unavailable')) 1592 .prop('disabled', true); 1593 1594 return; 1595 } 1596 const prefix = $btn.data('confirm-prefix') || ''; 1597 $btn.text(prefix + ' ' + utils.formatDate(this.selectedDateString)) 1598 .prop('disabled', false); 1599 }, 1600 1601 /** 1602 * Navigate to the URL of the clicked product radio button. 1603 * 1604 * @param {HTMLInputElement} input 1605 */ 1606 onSelectProductRadio: function(input) { 1607 const url = input.dataset.productUrl; 1608 if (url) { 1609 window.location.href = url; 1610 } 1611 }, 1612 1613 /** 1614 * Enable/disable the add-to-cart button based on date selection and quantity. 1615 * Only active when showProductsBeforeDate is true; otherwise normal flow handles state. 1616 */ 1617 updateAddToCartState: function() { 1618 if ( ! this.showProductsBeforeDate) { 1619 return; 1620 } 1621 const $btn = $('[data-content="ticket-calendar-add-to-cart"]'); 1622 if ($btn.length === 0) { 1623 return; 1624 } 1625 let totalQuantity = 0; 1626 $('[data-content="ticket-calendar-products-form"] input[type="number"]').each(function() { 1627 totalQuantity += parseInt($(this).val(), 10) || 0; 1628 }); 1629 $btn.prop('disabled', ! this.selectedDateString || totalQuantity === 0); 1630 }, 1631 1632 /** 1633 * Close the modal/offcanvas, update the trigger button with the confirmed date, and only 1634 * now apply that date to the main page (product/quantity form, total, add-to-cart) â see 1635 * selectDate() for why those are held back until this point. 1636 */ 1637 onConfirmModalDate: function() { 1638 if ( ! this.selectedDateString || ! this.isDayBookable(this.selectedDateString)) { 1639 return; 1640 } 1641 this.confirmedDateString = this.selectedDateString; 1642 this.applyQuantityCoverageReset(this.selectedDateString); 1643 this.updateFlow(); 1644 const element = document.getElementById(this.modalContainerId); 1645 if (element) { 1646 if (this.modalStyle === 'offcanvas') { 1647 const instance = bootstrap.Offcanvas.getInstance(element); 1648 if (instance) { 1649 instance.hide(); 1650 } 1651 } 1652 else { 1653 const instance = bootstrap.Modal.getInstance(element); 1654 if (instance) { 1655 instance.hide(); 1656 } 1657 } 1658 } 1659 $('[data-content="ticket-calendar-modal-trigger"] [data-content="trigger-label"]') 1660 .text(utils.formatDate(this.selectedDateString)); 1661 }, 1662 1663 /** 1664 * Fires whenever the modal/offcanvas is closed, whether via Confirm, Esc, the close 1665 * button, or the backdrop. If it closed WITHOUT confirming a newly browsed date, 1666 * selectedDateString has drifted from confirmedDateString â restore it so the 1667 * calendar reopens on the actually-confirmed date instead of the abandoned one. 1668 */ 1669 onDismissModal: function() { 1670 if (this.selectedDateString === this.confirmedDateString) { 1671 return; 1672 } 1673 this.selectedDateString = this.confirmedDateString; 1674 if (this.flatpickrInstance) { 1675 this.flatpickrInstance.setDate(this.selectedDateString, false); 1676 } 1677 this.updateModalConfirmButton(); 1678 } 1679 1680};
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.