PageSourceSearch

https://billet.aalborgzoo.dk/assets/event-shop/scripts/ticketCalendar-382da01112e9458932a79c212a11e3a8.js

js aalborgzoo.dk collected 2026-10-02 06:45:22 UTC 50,532 bytes, 1,680 lines download raw bytes

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.