PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 2.4.8
Booking for Appointments and Events Calendar – Amelia v2.4.8
2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / v3 / src / views / public / Parts / Calendar.vue
ameliabooking / v3 / src / views / public / Parts Last commit date
Congratulations 1 month ago Payment 2 days ago Skeletons 1 month ago BackLink.vue 1 month ago BookingSkeleton.vue 1 month ago Calendar.vue 2 days ago DotsPopup.vue 1 month ago RecurringSetup.vue 1 month ago
Calendar.vue
765 lines
1 <template>
2 <div
3 v-if="!calendarSlotsLoading"
4 class="am-fs-dt__calendar"
5 @pointerdown.capture="onCalendarPointerDown"
6 @click.capture="onCalendarClick"
7 >
8 <AmAdvancedSlotCalendar
9 :id="props.id"
10 :slots="calendarEvents"
11 :calendar-minimum-date="calendarMinimumDate"
12 :calendar-maximum-date="calendarMaximumDate"
13 :not-multiple="props.notMultiple"
14 :end-time="props.endTime"
15 :time-zone="props.timeZone"
16 :show-busy-slots="props.showBusySlots"
17 :show-calendar-date-busyness="props.showCalendarDateBusyness"
18 :show-estimated-pricing="props.showEstimatedPricing"
19 :show-indicator-pricing="props.showIndicatorPricing"
20 :show-slot-pricing="props.showSlotPricing"
21 :show-people-waiting="props.showPeopleWaiting"
22 :nested-item="nested"
23 :label-slots-selected="props.labelSlotsSelected"
24 :label-waiting-list="props.labelWaitingList"
25 :busyness="busyness"
26 :date="props.date"
27 :service-id="props.serviceId"
28 :tax-visibility="props.taxVisibility"
29 :tax-label="props.taxLabel"
30 :tax-label-incl="props.taxLabelIncl"
31 :period-pricing="periodPricing"
32 @selected-date="setSelectedDate"
33 @selected-time="setSelectedTime"
34 @changed-month="changeMonth"
35 @rendered-month="renderedMonth"
36 @unselect-date="unselectDate"
37 @selected-duration="setSelectedDuration"
38 @waiting-list-slot="setWaitingListSlot"
39 ></AmAdvancedSlotCalendar>
40 </div>
41
42 <!-- Skeleton -->
43 <el-skeleton
44 v-else
45 animated
46 class="am-skeleton-slots"
47 :class="checkScreen ? 'am-skeleton-slots-mobile' : ''"
48 >
49 <template #template>
50 <div class="am-skeleton-slots-filters">
51 <el-skeleton-item v-for="item in new Array(4)" :key="item" variant="text" />
52 </div>
53 <div class="am-skeleton-slots-weekdays">
54 <el-skeleton-item v-for="item in new Array(7)" :key="item" variant="text" />
55 </div>
56 <div class="am-skeleton-slots-days">
57 <el-skeleton-item v-for="item in new Array(42)" :key="item" variant="text" />
58 </div>
59 </template>
60 </el-skeleton>
61 <!-- /Skeleton -->
62 </template>
63
64 <script setup>
65 // * Import from Vue
66 import { ref, provide, inject, computed, nextTick } from 'vue'
67
68 // * Import from vuex
69 import { useStore } from 'vuex'
70
71 // * Dedicated component
72 import AmAdvancedSlotCalendar from '../../_components/advanced-slot-calendar/AmAdvancedSlotCalendar'
73
74 // * Composables
75 import { useCartHasItems, useCartItem, useCartStep } from '../../../assets/js/public/cart.js'
76 import { useCalendarEvents, useDuration } from '../../../assets/js/common/appointments.js'
77 import { useAppointmentSlots, useSlotsPricing } from '../../../assets/js/public/slots'
78
79 // * Plugin Licence
80 let licence = inject('licence')
81
82 // * Component props
83 const props = defineProps({
84 slotsParams: {
85 type: Object,
86 default: () => {},
87 },
88 id: {
89 type: Number,
90 default: 0,
91 },
92 serviceId: {
93 type: Number,
94 default: 0,
95 },
96 date: {
97 type: String,
98 default: '',
99 },
100 loadCounter: {
101 type: Number,
102 default: 0,
103 },
104 preselectSlot: {
105 type: Boolean,
106 default: false,
107 },
108 notMultiple: {
109 type: Boolean,
110 default: true,
111 },
112 endTime: {
113 type: Boolean,
114 default: true,
115 },
116 timeZone: {
117 type: Boolean,
118 default: true,
119 },
120 labelSlotsSelected: {
121 type: String,
122 default: '',
123 },
124 labelWaitingList: {
125 type: String,
126 default: '',
127 },
128 fetchedSlots: {
129 type: Object,
130 default: () => {},
131 },
132 inCollapse: {
133 type: Boolean,
134 default: false,
135 },
136 showBusySlots: {
137 type: Boolean,
138 default: false,
139 },
140 showCalendarDateBusyness: {
141 type: Boolean,
142 default: true,
143 },
144 showEstimatedPricing: {
145 type: Boolean,
146 default: false,
147 },
148 showIndicatorPricing: {
149 type: Boolean,
150 default: false,
151 },
152 showPeopleWaiting: {
153 type: Boolean,
154 default: true,
155 },
156 showSlotPricing: {
157 type: Boolean,
158 default: false,
159 },
160 isPackage: {
161 type: Boolean,
162 default: false,
163 },
164 taxVisibility: {
165 type: Boolean,
166 default: false,
167 },
168 taxLabel: {
169 type: String,
170 default: '',
171 },
172 taxLabelIncl: {
173 type: String,
174 default: '',
175 },
176 disableWaitingList: {
177 type: Boolean,
178 default: false,
179 },
180 })
181
182 const emits = defineEmits(['loadingSlots'])
183
184 let nested = computed(() => {
185 return {
186 inCollapse: props.inCollapse,
187 }
188 })
189
190 const store = useStore()
191
192 let searchStart = ref(null)
193
194 let searchEnd = ref(null)
195
196 let selectedYearMonth = ref('')
197
198 function loadSlots(customCallback) {
199 let range = useRange(store)
200
201 if (range) {
202 searchStart.value = range.start
203
204 searchEnd.value = range.end
205 }
206
207 getSlots(customCallback)
208 }
209
210 watch(
211 () => props.loadCounter,
212 () => {
213 loadSlots(() => {})
214 },
215 )
216
217 let cWidth = inject('containerWidth', 0)
218
219 let checkScreen = computed(() => cWidth.value < 560 || cWidth.value - 240 < 520)
220
221 let busyness = computed(() => store.getters['booking/getBusyness'])
222
223 /*****************
224 * Calendar Data *
225 ****************/
226
227 let calendarMinimumDate = ref(null)
228
229 let calendarMaximumDate = ref(null)
230
231 let calendarSlotsLoading = ref(true)
232
233 let calendarEvents = ref([])
234
235 let calendarEventDate = ref('')
236
237 let calendarEventSlots = ref([])
238
239 let calendarEventBusySlots = ref([])
240
241 // All waiting list slots returned from backend (date->time->providers array)
242 let calendarWaitingListSlots = ref({})
243 // Waiting list times for currently selected date (array of time strings)
244 let calendarWaitingListTimes = ref([])
245
246 let calendarEventSlot = ref('')
247
248 let calendarStartDate = ref(null)
249
250 let calendarChangeSideBar = inject('calendarChangeSideBar')
251
252 let calendarSlotDuration = inject('calendarSlotDuration')
253
254 let calendarServiceDuration = inject('calendarServiceDuration')
255
256 let useSlotsCallback = inject('useSlotsCallback')
257 let useSelectedDuration = inject('useSelectedDuration')
258 let useSelectedDate = inject('useSelectedDate')
259 let useBusySlots = inject('useBusySlots')
260 let useSelectedTime = inject('useSelectedTime')
261 let useDeselectedDate = inject('useDeselectedDate')
262 let useRange = inject('useRange')
263 // Cart step controls (optional injections - not needed in customer panel)
264 const injectedAddCartStep = inject('addCartStep', null)
265 const injectedRemoveCartStep = inject('removeCartStep', null)
266 // Steps data (to verify if CartStep exists before add/remove)
267 const stepsArray = inject('stepsArray', null)
268 // Origin key to detect context (capc = customer panel, cape = employee panel)
269 const originKey = inject('originKey', null)
270
271 provide('calendarEvents', calendarEvents)
272
273 provide('calendarEventDate', calendarEventDate)
274
275 provide('calendarEventSlots', calendarEventSlots)
276
277 provide('calendarEventBusySlots', calendarEventBusySlots)
278
279 provide('calendarWaitingListTimes', calendarWaitingListTimes)
280
281 provide('calendarWaitingListSlots', calendarWaitingListSlots)
282
283 provide('calendarEventSlot', calendarEventSlot)
284
285 provide('calendarStartDate', calendarStartDate)
286
287 provide('calendarChangeSideBar', calendarChangeSideBar)
288
289 // Capture DOM clicks inside calendar to compare real day-number clicks vs. gap/padding
290 function onCalendarClick(e) {
291 if (iOS()) return
292 let target = e.target
293 let cell =
294 target && typeof target.closest === 'function'
295 ? target.closest('td[data-date],td.fc-day,td.fc-daygrid-day')
296 : null
297 if (!cell) return
298
299 let dateAttr = (cell.dataset && cell.dataset.date) || cell.getAttribute('data-date')
300 if (!dateAttr) return
301
302 const classList = cell.classList ? Array.from(cell.classList) : []
303 const isDisabledCell = classList.some((cls) => cls && cls.toLowerCase().includes('disabled'))
304 if (isDisabledCell) {
305 return
306 }
307
308 // Toggle off when clicking the already selected date
309 if (dateAttr === calendarEventDate.value) {
310 unselectDate()
311 if (typeof e.stopPropagation === 'function') e.stopPropagation()
312 if (typeof e.preventDefault === 'function') e.preventDefault()
313 return
314 }
315
316 // Always treat day-cell click as selecting that date
317 setSelectedDate(dateAttr)
318 if (typeof e.stopPropagation === 'function') e.stopPropagation()
319 if (typeof e.preventDefault === 'function') e.preventDefault()
320 }
321
322 // Stop early pointer/mouse events from reaching the inner calendar when they would toggle unselect
323 function onCalendarPointerDown(e) {
324 if (iOS()) return
325 let target = e.target
326 let cell =
327 target && typeof target.closest === 'function'
328 ? target.closest('td[data-date],td.fc-day,td.fc-daygrid-day')
329 : null
330 if (!cell) return
331
332 const classList = cell.classList ? Array.from(cell.classList) : []
333 const isDisabledCell = classList.some((cls) => cls && cls.toLowerCase().includes('disabled'))
334 if (isDisabledCell) {
335 return
336 }
337 }
338 // iOS detection helper
339 function iOS() {
340 return (
341 ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'].includes(
342 navigator.platform,
343 ) ||
344 // iPad on iOS 13 detection
345 (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
346 )
347 }
348 // Helper: prune waiting list times using backend minimumDateTime and current time (today)
349 function filterWaitingListTimes(dateStr, timesArray, minimumDateTime) {
350 if (!Array.isArray(timesArray) || !timesArray.length) return []
351 const todayStr = new Date().toISOString().slice(0, 10)
352 let minTimeForDate = null
353 if (minimumDateTime) {
354 const parts = minimumDateTime.split(' ')
355 if (parts.length === 2) {
356 const [minDate, minTime] = parts
357 if (minDate === dateStr) {
358 minTimeForDate = minTime.slice(0, 5) // HH:mm
359 }
360 }
361 }
362 if (dateStr === todayStr) {
363 const now = new Date()
364 const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
365 if (!minTimeForDate || nowTime.localeCompare(minTimeForDate) > 0) {
366 minTimeForDate = nowTime
367 }
368 }
369 let filtered = timesArray
370 if (minTimeForDate) {
371 filtered = filtered.filter((t) => t.localeCompare(minTimeForDate) > 0)
372 }
373 return filtered.sort((a, b) => a.localeCompare(b))
374 }
375
376 /*********
377 * Other *
378 ********/
379
380 let periodPricing = ref({})
381
382 function setSelectedDuration(value) {
383 store.commit('booking/setBookingDuration', value)
384
385 let cartItem = useCartItem(store)
386
387 let service = store.getters['entities/getService'](cartItem.serviceId)
388
389 let extrasIds = store.getters['booking/getSelectedExtras'].map((i) => i.extraId)
390
391 calendarSlotDuration.value = useDuration(
392 value,
393 service.extras.filter((i) => extrasIds.includes(i.id)),
394 )
395
396 calendarServiceDuration.value = value
397
398 nextTick(() => {
399 getSlots(() => {})
400 })
401 }
402
403 function setSelectedDate(value) {
404 calendarEventSlots.value = useSelectedDate(store, value, {
405 start: searchStart.value,
406 end: searchEnd.value,
407 })
408
409 calendarEventBusySlots.value = useBusySlots(store)
410
411 // Derive waiting list times for the selected date
412 if (!skipWaitingListSlots() && value && value in calendarWaitingListSlots.value) {
413 calendarWaitingListTimes.value = filterWaitingListTimes(
414 value,
415 Object.keys(calendarWaitingListSlots.value[value] || {}),
416 calendarMinimumDate.value,
417 )
418 } else {
419 calendarWaitingListTimes.value = []
420 }
421
422 if (props.preselectSlot && calendarEventSlots.value.length) {
423 setSelectedTime(calendarEventSlots.value[0])
424 }
425
426 calendarEventDate.value = value
427 }
428
429 function setSelectedTime(value) {
430 useSelectedTime(store, value)
431
432 calendarEventSlot.value = value
433 }
434
435 function setWaitingListSlot(isWaiting, peopleWaiting, providerId = null) {
436 store.commit('appointmentWaitingListOptions/setIsWaitingListSlot', !!isWaiting)
437 store.commit('appointmentWaitingListOptions/setPeopleWaiting', peopleWaiting)
438 store.commit('appointmentWaitingListOptions/setSelectedProviderId', providerId)
439 // Determine if CartStep currently present
440 let hasCartStep = false
441 if (stepsArray && Array.isArray(stepsArray.value)) {
442 hasCartStep = stepsArray.value.some((s) => s && s.name === 'CartStep')
443 }
444 if (isWaiting) {
445 // Remove cart step only if it exists
446 if (
447 hasCartStep &&
448 injectedRemoveCartStep &&
449 typeof injectedRemoveCartStep.removeCartStep === 'function'
450 ) {
451 injectedRemoveCartStep.removeCartStep()
452 }
453 } else {
454 // Re-add cart step only if missing and cart feature enabled
455 if (
456 !props.disableWaitingList &&
457 !props.isPackage &&
458 !hasCartStep &&
459 useCartStep(store) &&
460 injectedAddCartStep &&
461 typeof injectedAddCartStep.addCartStep === 'function'
462 ) {
463 injectedAddCartStep.addCartStep()
464 // Retry once on next tick in case steps array mutates asynchronously
465 nextTick(() => {
466 let existsAfter =
467 stepsArray &&
468 Array.isArray(stepsArray.value) &&
469 stepsArray.value.some((s) => s && s.name === 'CartStep')
470 if (!existsAfter) {
471 if (useCartStep(store)) {
472 injectedAddCartStep.addCartStep()
473 }
474 }
475 })
476 }
477 }
478 }
479
480 // Skip waiting list slots for: recurring / cart / package / customer panel (capc)
481 function skipWaitingListSlots() {
482 return (
483 props.disableWaitingList ||
484 props.isPackage ||
485 useCartHasItems(store) ||
486 (originKey && originKey.value === 'capc')
487 )
488 }
489
490 function unselectDate() {
491 useDeselectedDate(store)
492
493 calendarEventSlots.value = []
494
495 calendarEventSlot.value = ''
496
497 calendarEventDate.value = ''
498
499 calendarEventBusySlots.value = []
500
501 calendarWaitingListTimes.value = []
502 }
503
504 function changeMonth(yearMonth) {
505 selectedYearMonth.value = yearMonth
506
507 getSlots(() => {})
508 }
509
510 function renderedMonth(data) {
511 searchStart.value = data.start
512
513 searchEnd.value = data.end
514 }
515
516 function getSlotsCallback(
517 slots,
518 occupied,
519 minimumDateTime,
520 maximumDateTime,
521 busyness,
522 appCount,
523 lastBookedProviderId,
524 customCallback,
525 waitingListSlots = {},
526 requestedServiceId,
527 requestedCartItemIndex,
528 ) {
529 calendarMinimumDate.value = minimumDateTime
530 calendarMaximumDate.value = maximumDateTime
531
532 calendarEvents.value = useCalendarEvents(slots, waitingListSlots)
533
534 calendarWaitingListSlots.value = waitingListSlots || {}
535
536 let result = useSlotsCallback(
537 store,
538 slots,
539 occupied,
540 minimumDateTime,
541 maximumDateTime,
542 busyness,
543 appCount,
544 lastBookedProviderId,
545 searchStart,
546 searchEnd,
547 requestedServiceId,
548 requestedCartItemIndex,
549 )
550
551 if (props.serviceId) {
552 let service = store.getters['entities/getService'](props.serviceId)
553
554 let periodPricingResult =
555 !props.isPackage &&
556 service.customPricing.enabled === 'period' &&
557 !licence.isLite &&
558 !licence.isStarter &&
559 !licence.isBasic
560 ? useSlotsPricing(store, slots, service.id)
561 : null
562
563 periodPricing.value = periodPricingResult ? periodPricingResult : null
564 }
565
566 if ('calendarStartDate' in result) {
567 calendarStartDate.value = selectedYearMonth.value
568 ? selectedYearMonth.value + '-01'
569 : result.calendarStartDate
570 }
571
572 if ('calendarEventSlot' in result) {
573 calendarEventSlot.value = result.calendarEventSlot
574 }
575
576 if ('calendarEventSlots' in result) {
577 calendarEventSlots.value = result.calendarEventSlots
578 }
579
580 if ('calendarEventDate' in result) {
581 calendarEventDate.value = result.calendarEventDate
582 }
583
584 calendarEventBusySlots.value = calendarEventDate.value ? useBusySlots(store) : []
585
586 // Refresh waiting list times for currently selected date (clear if none)
587 if (!skipWaitingListSlots()) {
588 const selectedDate = store.getters['booking/getMultipleAppointmentsDate']
589 if (selectedDate && calendarWaitingListSlots.value[selectedDate]) {
590 calendarWaitingListTimes.value = filterWaitingListTimes(
591 selectedDate,
592 Object.keys(calendarWaitingListSlots.value[selectedDate] || {}),
593 minimumDateTime,
594 )
595 } else {
596 calendarWaitingListTimes.value = []
597 }
598 } else {
599 calendarWaitingListTimes.value = []
600 }
601
602 nextTick(() => {
603 customCallback()
604
605 calendarSlotsLoading.value = false
606
607 emits('loadingSlots', false)
608 })
609 }
610
611 function getSlots(customCallback) {
612 calendarSlotsLoading.value = true
613
614 emits('loadingSlots', true)
615
616 const slotsParams = Object.assign(
617 {
618 startDateTime: searchStart.value,
619 endDateTime: searchEnd.value,
620 },
621 props.slotsParams,
622 )
623
624 const cartItemIndex = store.getters['booking/getCartItemIndex']
625
626 if (slotsParams.serviceId) {
627 store.commit('booking/setBusyness', {
628 serviceId: slotsParams.serviceId,
629 busyness: {},
630 cartItemIndex,
631 })
632 }
633
634 useAppointmentSlots(
635 slotsParams,
636 props.fetchedSlots,
637 getSlotsCallback,
638 customCallback,
639 store.getters['appointmentWaitingListOptions/getOptions'],
640 // Resolve at response time from reactive sources so a service switch can
641 // discard stale requests. Fall back to the request snapshot only for
642 // packages, where booking.appointment.serviceId stays null.
643 () => {
644 const activeServiceId = props.serviceId || store.getters['booking/getServiceId']
645
646 if (activeServiceId) {
647 return activeServiceId
648 }
649
650 return props.isPackage ? slotsParams.serviceId : null
651 },
652 cartItemIndex,
653 )
654 }
655
656 function unsetData() {
657 calendarEventSlots.value = []
658
659 calendarEventSlot.value = ''
660 }
661
662 defineExpose({
663 loadSlots,
664 unsetData,
665 calendarSlotsLoading,
666 })
667 </script>
668
669 <script>
670 export default {
671 name: 'CalendarBlock',
672 }
673 </script>
674
675 <style lang="scss">
676 // am -- amelia
677 // fs -- form steps
678
679 .amelia-v2-booking #amelia-container {
680 // Amelia Form Steps
681 .am-fs {
682 // Container Wrapper
683 &__main {
684 &-heading {
685 &-inner {
686 display: flex;
687 align-items: center;
688
689 .am-heading-prev {
690 margin-right: 12px;
691 }
692 }
693 }
694 &-inner {
695 &#{&}-dt {
696 padding: 0 20px;
697 }
698 }
699 }
700 }
701
702 // Skeleton
703 .am-skeleton-slots {
704 &-mobile {
705 padding: 0px;
706
707 .am-skeleton-slots-days {
708 gap: 6px;
709
710 .el-skeleton__item {
711 height: 28px;
712 max-width: 56px;
713 }
714 }
715 }
716
717 &-filters {
718 display: flex;
719 flex-direction: row;
720 justify-content: space-between;
721 padding: 0 0 24px;
722
723 .el-skeleton__item {
724 height: 36px;
725 width: 20%;
726 }
727
728 :first-child {
729 width: 26%;
730 margin-right: 16px;
731 }
732
733 :last-child {
734 width: 16%;
735 margin-left: 16px;
736 }
737 }
738
739 &-weekdays {
740 padding-bottom: 12px;
741 display: flex;
742 flex-direction: row;
743 justify-content: space-around;
744
745 .el-skeleton__item {
746 max-width: 30px;
747 height: 24px;
748 }
749 }
750
751 &-days {
752 display: grid;
753 grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
754 gap: 8px;
755
756 .el-skeleton__item {
757 margin: 0 1.5px;
758 height: 40px;
759 max-width: 56px;
760 }
761 }
762 }
763 }
764 </style>
765