PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 2.4.6
Booking for Appointments and Events Calendar – Amelia v2.4.6
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 1 month ago Skeletons 1 month ago BackLink.vue 1 month ago BookingSkeleton.vue 1 month ago Calendar.vue 1 month ago DotsPopup.vue 1 month ago RecurringSetup.vue 1 month ago
Calendar.vue
736 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 ) {
527 calendarMinimumDate.value = minimumDateTime
528 calendarMaximumDate.value = maximumDateTime
529
530 calendarEvents.value = useCalendarEvents(slots, waitingListSlots)
531
532 calendarWaitingListSlots.value = waitingListSlots || {}
533
534 let result = useSlotsCallback(
535 store,
536 slots,
537 occupied,
538 minimumDateTime,
539 maximumDateTime,
540 busyness,
541 appCount,
542 lastBookedProviderId,
543 searchStart,
544 searchEnd,
545 )
546
547 if (props.serviceId) {
548 let service = store.getters['entities/getService'](props.serviceId)
549
550 let periodPricingResult =
551 !props.isPackage &&
552 service.customPricing.enabled === 'period' &&
553 !licence.isLite &&
554 !licence.isStarter &&
555 !licence.isBasic
556 ? useSlotsPricing(store, slots, service.id)
557 : null
558
559 periodPricing.value = periodPricingResult ? periodPricingResult : null
560 }
561
562 if ('calendarStartDate' in result) {
563 calendarStartDate.value = selectedYearMonth.value
564 ? selectedYearMonth.value + '-01'
565 : result.calendarStartDate
566 }
567
568 if ('calendarEventSlot' in result) {
569 calendarEventSlot.value = result.calendarEventSlot
570 }
571
572 if ('calendarEventSlots' in result) {
573 calendarEventSlots.value = result.calendarEventSlots
574 }
575
576 if ('calendarEventDate' in result) {
577 calendarEventDate.value = result.calendarEventDate
578 }
579
580 calendarEventBusySlots.value = calendarEventDate.value ? useBusySlots(store) : []
581
582 // Refresh waiting list times for currently selected date (clear if none)
583 if (!skipWaitingListSlots()) {
584 const selectedDate = store.getters['booking/getMultipleAppointmentsDate']
585 if (selectedDate && calendarWaitingListSlots.value[selectedDate]) {
586 calendarWaitingListTimes.value = filterWaitingListTimes(
587 selectedDate,
588 Object.keys(calendarWaitingListSlots.value[selectedDate] || {}),
589 minimumDateTime,
590 )
591 } else {
592 calendarWaitingListTimes.value = []
593 }
594 } else {
595 calendarWaitingListTimes.value = []
596 }
597
598 nextTick(() => {
599 customCallback()
600
601 calendarSlotsLoading.value = false
602
603 emits('loadingSlots', false)
604 })
605 }
606
607 function getSlots(customCallback) {
608 calendarSlotsLoading.value = true
609
610 emits('loadingSlots', true)
611
612 useAppointmentSlots(
613 Object.assign(
614 {
615 startDateTime: searchStart.value,
616 endDateTime: searchEnd.value,
617 },
618 props.slotsParams,
619 ),
620 props.fetchedSlots,
621 getSlotsCallback,
622 customCallback,
623 store.getters['appointmentWaitingListOptions/getOptions'],
624 )
625 }
626
627 function unsetData() {
628 calendarEventSlots.value = []
629
630 calendarEventSlot.value = ''
631 }
632
633 defineExpose({
634 loadSlots,
635 unsetData,
636 calendarSlotsLoading,
637 })
638 </script>
639
640 <script>
641 export default {
642 name: 'CalendarBlock',
643 }
644 </script>
645
646 <style lang="scss">
647 // am -- amelia
648 // fs -- form steps
649
650 .amelia-v2-booking #amelia-container {
651 // Amelia Form Steps
652 .am-fs {
653 // Container Wrapper
654 &__main {
655 &-heading {
656 &-inner {
657 display: flex;
658 align-items: center;
659
660 .am-heading-prev {
661 margin-right: 12px;
662 }
663 }
664 }
665 &-inner {
666 &#{&}-dt {
667 padding: 0 20px;
668 }
669 }
670 }
671 }
672
673 // Skeleton
674 .am-skeleton-slots {
675 &-mobile {
676 padding: 0px;
677
678 .am-skeleton-slots-days {
679 gap: 6px;
680
681 .el-skeleton__item {
682 height: 28px;
683 max-width: 56px;
684 }
685 }
686 }
687
688 &-filters {
689 display: flex;
690 flex-direction: row;
691 justify-content: space-between;
692 padding: 0 0 24px;
693
694 .el-skeleton__item {
695 height: 36px;
696 width: 20%;
697 }
698
699 :first-child {
700 width: 26%;
701 margin-right: 16px;
702 }
703
704 :last-child {
705 width: 16%;
706 margin-left: 16px;
707 }
708 }
709
710 &-weekdays {
711 padding-bottom: 12px;
712 display: flex;
713 flex-direction: row;
714 justify-content: space-around;
715
716 .el-skeleton__item {
717 max-width: 30px;
718 height: 24px;
719 }
720 }
721
722 &-days {
723 display: grid;
724 grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
725 gap: 8px;
726
727 .el-skeleton__item {
728 margin: 0 1.5px;
729 height: 40px;
730 max-width: 56px;
731 }
732 }
733 }
734 }
735 </style>
736