PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 2.4.7
Booking for Appointments and Events Calendar – Amelia v2.4.7
2.4.9 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 / Cabinet / common / parts / QrCodeScanner.vue
ameliabooking / v3 / src / views / public / Cabinet / common / parts Last commit date
CollapseCard 1 month ago AmQuill.vue 1 month ago AppointmentBooking.vue 2 weeks ago CancelPopup.vue 1 month ago DeleteProfile.vue 1 month ago DescriptionEditor.vue 1 month ago EmptyState.vue 1 month ago Filters.vue 1 month ago PaymentButton.vue 1 month ago QrCodeScanner.vue 1 month ago Skeleton.vue 1 month ago TimeZoneSelect.vue 1 month ago
QrCodeScanner.vue
810 lines
1 <template>
2 <AmSlidePopup
3 v-if="props.visibility"
4 :visibility="props.visibility"
5 class="am-cqcs__dialog"
6 position="top"
7 :footer-visibility="false"
8 @update:visibility="(value) => emits('update:visibility', value)"
9 >
10 <template #header>
11 <div class="am-cqcs__header">
12 {{ amLabels.e_ticket_scanner }}
13 </div>
14 </template>
15
16 <div v-if="props.visibility" class="am-cqcs__container">
17 <div v-show="!dataLoading" class="am-cqcs__container-inner">
18 <div class="am-cqcs__camera-container">
19 <video
20 ref="videoElement"
21 class="am-cqcs__video"
22 :style="{ display: isScanning ? 'block' : 'none' }"
23 autoplay
24 playsinline
25 />
26 <canvas ref="canvasElement" class="am-cqcs__canvas" style="display: none" />
27
28 <!-- Scanner overlay -->
29 <div class="am-cqcs__video-overlay">
30 <div class="am-cqcs__frame" />
31 </div>
32 </div>
33
34 <div v-if="responseData" class="am-cqcs__response-info">
35 <span
36 :class="
37 responseData.messageType === 'error'
38 ? 'am-icon-close-filled'
39 : 'am-icon-checkmark-circle-full'
40 "
41 />
42 <div v-if="responseData.message" class="am-cqcs__response-message">
43 {{ amLabels[responseData.message] || responseData.message }}
44 </div>
45 <div v-if="responseData.eventName" class="am-cqcs__response-name">
46 {{ responseData.eventName }}
47 </div>
48 <div v-if="responseData.ticketManualCode" class="am-cqcs__response-item">
49 <span>{{ amLabels.ticket_id }}:</span> #{{ responseData.ticketManualCode }}
50 </div>
51 <div v-if="responseData.eventTicketName" class="am-cqcs__response-item">
52 <span>{{ amLabels.ticket_name }}:</span>
53 {{ responseData.eventTicketName }}
54 </div>
55 <div v-if="responseData.bookingId" class="am-cqcs__response-item">
56 <span>{{ amLabels.booking_id }}:</span> #{{ responseData.bookingId }}
57 </div>
58 <div v-if="responseData.ticketControlNumber" class="am-cqcs__response-item">
59 <span>{{ amLabels.attendees_allowed }}:</span>
60 {{ responseData.ticketControlNumber }}
61 </div>
62 </div>
63
64 <div v-if="scanError" class="am-cqcs__error">
65 <span class="am-icon-close-filled" />
66 {{ scanError }}
67 </div>
68
69 <div class="am-cqcs__controls">
70 <AmButton v-if="torchSupported" category="primary" @click="toggleTorch">
71 <span :class="torchActive ? 'am-icon-bolt-lightning' : 'am-icon-bolt-lightning-fill'" />
72 {{ torchActive ? amLabels.torch_off : amLabels.torch_on }}
73 </AmButton>
74
75 <AmButton v-if="cameras.length > 1" @click="switchCamera">
76 <span class="am-icon-refresh" />
77 {{ amLabels.switch_camera }}
78 </AmButton>
79
80 <AmButton v-if="!isScanning" category="primary" @click="startScanning">
81 <span class="am-icon-mobile" />
82 {{ amLabels.start_scanner }}
83 </AmButton>
84
85 <AmButton v-if="isScanning" category="danger" @click="stopScanning">
86 <span class="am-icon-mobile" />
87 {{ amLabels.stop_scanner }}
88 </AmButton>
89 </div>
90
91 <!-- Manual form -->
92 <AmCollapse class="am-cqcs__collapse">
93 <AmCollapseItem ref="manualCodeCollapse" class="am-cqcs__collapse-item">
94 <template #heading>
95 {{ amLabels.enter_ticket_manually }}
96 </template>
97 <el-form
98 ref="manualCodeForm"
99 :model="manualCode"
100 :rules="rules"
101 class="am-cqcs__form"
102 @submit.prevent="processManualInput"
103 >
104 <template v-for="(item, name) in manualCodeConstruction" :key="name">
105 <component :is="item.template" v-model="manualCode[name]" v-bind="item.props" />
106 </template>
107 <el-form-item>
108 <AmButton category="primary" @click="processManualInput">
109 {{ amLabels.validate_ticket }}
110 </AmButton>
111 </el-form-item>
112 </el-form>
113 </AmCollapseItem>
114 </AmCollapse>
115 <!-- /Manual form -->
116 </div>
117 <Skeleton v-if="dataLoading"></Skeleton>
118 </div>
119 </AmSlidePopup>
120 </template>
121
122 <script setup>
123 // * Import from Vue
124 import { ref, watch, onMounted, inject, onUnmounted } from 'vue'
125
126 // * Import from Vuex
127 import { useStore } from 'vuex'
128
129 // * Components
130 import AmSlidePopup from '../../../../_components/slide-popup/AmSlidePopup.vue'
131 import AmButton from '../../../../_components/button/AmButton.vue'
132 import AmCollapse from '../../../../_components/collapse/AmCollapse.vue'
133 import AmCollapseItem from '../../../../_components/collapse/AmCollapseItem.vue'
134 import Skeleton from './Skeleton.vue'
135
136 // * Import Form Templates
137 import { formFieldsTemplates } from '../../../../../assets/js/common/formFieldsTemplates'
138
139 // * Libraries
140 import jsQR from 'jsqr'
141 import moment from 'moment/moment'
142
143 // * Composables
144 import httpClient from '../../../../../plugins/axios'
145
146 // * Props
147 const props = defineProps({
148 visibility: {
149 type: Boolean,
150 default: false,
151 },
152 })
153
154 // * Store
155 let store = useStore()
156
157 // * Emits
158 const emits = defineEmits(['update:visibility'])
159
160 // * Labels
161 let amLabels = inject('labels')
162
163 let dataLoading = ref(false)
164 let isScanning = ref(false)
165 let torchActive = ref(false)
166 let torchSupported = ref(false)
167 let cameras = ref([])
168 let currentCameraIndex = ref(0)
169 let scanError = ref('')
170 let stream = ref(null)
171
172 // Added for jsQR loop
173 let frameRequestId = ref(null)
174 let lastDecodedAt = ref(0)
175 let decodeCooldownMs = ref(1500)
176
177 let responseData = ref(null)
178
179 // * Refs
180 let videoElement = ref(null)
181 let canvasElement = ref(null)
182
183 // * Manual Code Form
184 // Reference to the form
185 let manualCodeForm = ref(null)
186 // Form data
187 let manualCode = ref({
188 ticketCode: '',
189 })
190
191 // Form validation rules
192 let rules = ref({
193 ticketCode: [
194 {
195 required: true,
196 message: 'Ticket code is required',
197 trigger: 'click',
198 },
199 ],
200 })
201
202 let manualCodeConstruction = ref({
203 ticketCode: {
204 template: formFieldsTemplates.text,
205 props: {
206 itemName: 'ticketCode',
207 placeholder: amLabels.enter_ticket_code,
208 class: 'am-cqcs__form-item',
209 prepend: '#',
210 },
211 },
212 })
213
214 // Reference to the collapse component
215 let manualCodeCollapse = ref(null)
216
217 async function initializeCamera() {
218 try {
219 // Check if we're on HTTPS, localhost, or 127.0.0.1
220 const isSecureContext =
221 window.isSecureContext ||
222 location.protocol === 'https:' ||
223 location.hostname === 'localhost' ||
224 location.hostname === '127.0.0.1' ||
225 location.hostname.includes('ngrok.io') ||
226 location.hostname.startsWith('192.168.') ||
227 location.hostname.startsWith('10.') ||
228 location.hostname.startsWith('172.')
229
230 if (!isSecureContext) {
231 throw new Error(
232 'Camera access requires HTTPS or localhost. Please access this page over a secure connection or use localhost.',
233 )
234 }
235
236 // Check if MediaDevices API is supported
237 if (!navigator.mediaDevices) {
238 throw new Error(
239 'MediaDevices API not supported in this browser. Please update your browser or try Chrome/Firefox.',
240 )
241 }
242
243 if (!navigator.mediaDevices.getUserMedia) {
244 throw new Error(
245 'getUserMedia not supported in this browser. Please update your browser or try Chrome/Firefox.',
246 )
247 }
248
249 // Try to get available cameras with permission check
250 let devices = []
251 try {
252 devices = await navigator.mediaDevices.enumerateDevices()
253 } catch (enumError) {
254 console.warn('Could not enumerate devices:', enumError)
255 // Try to proceed without device enumeration
256 }
257
258 cameras.value = devices.filter((device) => device.kind === 'videoinput')
259
260 // Even if we can't enumerate devices, try to start scanning
261 // The browser will handle camera selection
262 await startScanning()
263 } catch (error) {
264 handleScannerError(error)
265 }
266 }
267
268 async function startScanning() {
269 if (manualCodeCollapse.value) {
270 manualCodeCollapse.value.closingFromParent()
271 }
272 responseData.value = null
273 try {
274 scanError.value = ''
275
276 // Check MediaDevices support again
277 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
278 throw new Error('Camera access not supported in this browser')
279 }
280
281 // Stop any existing stream
282 if (stream.value) {
283 stream.value.getTracks().forEach((track) => track.stop())
284 }
285
286 // Start with basic constraints
287 let constraints = {
288 video: {
289 facingMode: 'environment', // Prefer back camera
290 },
291 }
292
293 // If we have specific camera selected, use it
294 if (
295 cameras.value.length > 0 &&
296 cameras.value[currentCameraIndex.value] &&
297 cameras.value[currentCameraIndex.value].deviceId
298 ) {
299 constraints.video.deviceId = {
300 exact: cameras.value[currentCameraIndex.value].deviceId,
301 }
302 } else {
303 // Try with additional constraints for better quality
304 constraints.video = {
305 facingMode: 'environment',
306 width: { ideal: 1280, min: 640 },
307 height: { ideal: 720, min: 480 },
308 }
309 }
310
311 // Get media stream
312 stream.value = await navigator.mediaDevices.getUserMedia(constraints)
313
314 // Check torch support
315 const track = stream.value.getVideoTracks()[0]
316 if (track && track.getCapabilities) {
317 const capabilities = track.getCapabilities()
318 torchSupported.value = !!capabilities.torch
319 }
320
321 // Attach stream to video element
322 if (videoElement.value) {
323 videoElement.value.srcObject = stream.value
324
325 // Wait for video to load
326 videoElement.value.onloadedmetadata = () => {
327 videoElement.value.play().catch((e) => {
328 console.warn('Could not auto-play video:', e)
329 })
330 }
331 }
332
333 isScanning.value = true
334
335 // Start QR code detection (simplified version)
336 startQRDetection()
337 } catch (error) {
338 handleScannerError(error)
339 }
340 }
341
342 function stopScanning() {
343 responseData.value = null
344 isScanning.value = false
345 if (frameRequestId.value) {
346 cancelAnimationFrame(frameRequestId.value)
347 frameRequestId.value = null
348 }
349 if (stream.value) {
350 stream.value.getTracks().forEach((t) => t.stop())
351 stream.value = null
352 }
353 if (videoElement.value) videoElement.value.srcObject = null
354 }
355
356 function startQRDetection() {
357 const video = videoElement.value
358 const canvas = canvasElement.value
359 if (!video || !canvas) return
360 const ctx = canvas.getContext('2d', { willReadFrequently: true })
361
362 const scan = () => {
363 if (!isScanning.value) return
364 if (video.readyState === video.HAVE_ENOUGH_DATA) {
365 if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
366 canvas.width = video.videoWidth
367 canvas.height = video.videoHeight
368 }
369 ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
370 const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
371
372 const now = performance.now()
373 if (now - lastDecodedAt.value >= decodeCooldownMs.value) {
374 const qr = jsQR(imageData.data, imageData.width, imageData.height, {
375 inversionAttempts: 'dontInvert',
376 })
377 if (qr && qr.data) {
378 if (qr.data) {
379 onTicketScan(qr.data)
380 stopScanning()
381 }
382 lastDecodedAt.value = now
383 }
384 }
385 }
386 frameRequestId.value = requestAnimationFrame(scan)
387 }
388
389 frameRequestId.value = requestAnimationFrame(scan)
390 }
391
392 async function toggleTorch() {
393 if (!torchSupported.value || !stream.value) return
394
395 try {
396 const track = stream.value.getVideoTracks()[0]
397 await track.applyConstraints({
398 advanced: [{ torch: !torchActive.value }],
399 })
400 torchActive.value = !torchActive.value
401 } catch (error) {
402 console.error('Error toggling torch:', error)
403 }
404 }
405
406 async function switchCamera() {
407 if (cameras.value.length <= 1) return
408
409 currentCameraIndex.value = (currentCameraIndex.value + 1) % cameras.value.length
410 await startScanning()
411 }
412
413 function processManualInput() {
414 manualCodeForm.value.validate((valid) => {
415 if (valid) {
416 const ticketData = {
417 ticketManualCode: manualCode.value.ticketCode.trim(),
418 scannedAt: moment().format('YYYY-MM-DD'),
419 }
420 sendTicketData(ticketData)
421 // Clear form
422 manualCode.value.ticketCode = ''
423 } else {
424 return false
425 }
426 })
427 }
428
429 function parseQrCodeTicketString(string) {
430 const obj = {}
431 const regex = /(\w+)\s*:\s*([^\s|]+)/g
432 let m
433 while ((m = regex.exec(string)) !== null) {
434 let key = m[1]
435 let value = m[2]
436 if (/^-?\d+$/.test(value)) {
437 value = Number(value)
438 }
439 obj[key] = value
440 }
441 return obj
442 }
443
444 function onTicketScan(ticketCode) {
445 scanError.value = ''
446 let ticketDataResult = parseQrCodeTicketString(ticketCode)
447 const scannedAt = moment().format('YYYY-MM-DD')
448
449 let ticketData = {
450 ...ticketDataResult,
451 scannedAt,
452 }
453
454 sendTicketData(ticketData)
455 }
456
457 function sendTicketData(ticketData) {
458 dataLoading.value = true
459 scanError.value = ''
460 // Call the backend API to validate and check in the ticket
461 httpClient
462 .post(
463 '/scan-eticket',
464 {
465 ticketManualCode: ticketData.ticketManualCode,
466 scannedAt: ticketData.scannedAt,
467 },
468 {
469 params: {
470 source: 'cabinet-provider',
471 },
472 },
473 )
474 .then((response) => {
475 if (response.data && response.data.data) {
476 responseData.value = {
477 messageType: response.data.data.messageType,
478 message: response.data.data.message,
479 bookingId: response.data.data.bookingId,
480 ticketManualCode: response.data.data.ticketManualCode,
481 ticketControlNumber: response.data.data.ticketControl,
482 eventName: response.data.data.eventName
483 ? response.data.data.eventName
484 : getResponseEventName(response.data.data),
485 }
486
487 if (getResponseEventTicketName(response.data.data)) {
488 responseData.value.eventTicketName = getResponseEventTicketName(response.data.data)
489 }
490 }
491 })
492 .catch((error) => {
493 const resp = error?.response?.data
494
495 if (!resp) return
496
497 if (resp.data) {
498 responseData.value = {
499 messageType: resp.data.messageType,
500 message: resp.data.message,
501 }
502 } else {
503 responseData.value = {
504 messageType: 'error',
505 message: resp.message,
506 }
507
508 if (resp.message.includes('Mandatory fields not passed!')) {
509 responseData.value.message = amLabels.ticket_not_valid
510 }
511 }
512 })
513 .finally(() => {
514 dataLoading.value = false
515 })
516 }
517
518 function getResponseEventName(data) {
519 let qrItem = data.qrCodes.find((qr) => qr.ticketManualCode === data.ticketManualCode)
520
521 return qrItem.eventName || ''
522 }
523
524 function getResponseEventTicketName(data) {
525 let qrItem = data.qrCodes.find((qr) => qr.ticketManualCode === data.ticketManualCode)
526
527 return qrItem.eventTicketName || ''
528 }
529
530 function handleScannerError(error) {
531 console.error('Scanner error:', error)
532
533 if (error.name === 'NotAllowedError') {
534 scanError.value = amLabels.camera_error_1
535 } else if (error.name === 'NotFoundError') {
536 scanError.value = amLabels.camera_error_2
537 } else if (error.name === 'NotSupportedError') {
538 scanError.value = amLabels.camera_error_3
539 } else {
540 scanError.value = `Camera error: ${error.message}`
541 }
542 }
543
544 onMounted(() => {
545 watch(
546 () => props.visibility,
547 (newVal) => {
548 if (newVal) {
549 initializeCamera()
550 } else {
551 stopScanning()
552 }
553 },
554 { immediate: true },
555 )
556 })
557
558 onUnmounted(() => {
559 stopScanning()
560 })
561 </script>
562
563 <style lang="scss">
564 // * QR Scanner Styles
565 @mixin qr-code-scanner {
566 // - am - amelia
567 // - cqcs - cabinet-qr-code-scanner
568 .am-cqcs__dialog {
569 .am-cqcs__header {
570 font-size: 18px;
571 font-weight: 500;
572 font-style: normal;
573 line-height: 1.55;
574 text-transform: initial;
575 letter-spacing: initial;
576 text-align: center;
577 white-space: nowrap;
578 color: var(--am-c-main-heading-text);
579 padding: 0 0 16px;
580 margin: 0;
581 }
582 }
583
584 // Main scanner container
585 .am-cqcs {
586 // * Container for the entire scanner component
587 &__container {
588 position: relative;
589 display: block;
590 height: 626px;
591 overflow-x: hidden;
592
593 // Main Scroll styles
594 &::-webkit-scrollbar {
595 width: 6px;
596 }
597
598 &::-webkit-scrollbar-thumb {
599 border-radius: 6px;
600 background: var(--am-c-scroll-op30);
601 }
602
603 &::-webkit-scrollbar-track {
604 border-radius: 6px;
605 background: var(--am-c-scroll-op10);
606 }
607
608 // Inner wrapper to center content
609 &-inner {
610 width: 100%;
611 display: flex;
612 flex-direction: column;
613 align-items: center;
614 gap: 16px;
615 padding: 0;
616 margin: 0;
617 }
618 }
619
620 &__camera-container {
621 position: relative;
622 width: 100%;
623 max-width: 500px;
624 background: var(--am-c-spb-text);
625 border-radius: 8px;
626 overflow: hidden;
627 }
628
629 &__video {
630 width: 100%;
631 height: auto;
632 display: block;
633 }
634
635 &__video-overlay {
636 position: absolute;
637 top: 0;
638 left: 0;
639 right: 0;
640 bottom: 0;
641 display: flex;
642 align-items: center;
643 justify-content: center;
644 pointer-events: none;
645 }
646
647 &__frame {
648 width: 200px;
649 height: 200px;
650 border: 3px solid var(--am-c-primary);
651 border-radius: 8px;
652 background: transparent;
653 position: relative;
654
655 &::before,
656 &::after {
657 content: '';
658 position: absolute;
659 width: 20px;
660 height: 20px;
661 border: 3px solid var(--am-c-primary);
662 }
663
664 &::before {
665 top: -10px;
666 left: -10px;
667 border-right: none;
668 border-bottom: none;
669 border-radius: 10px 0 0 0;
670 }
671
672 &::after {
673 bottom: -10px;
674 right: -10px;
675 border-left: none;
676 border-top: none;
677 border-radius: 0 0 10px 0;
678 }
679 }
680
681 &__controls {
682 display: flex;
683 gap: 12px;
684 flex-wrap: wrap;
685 justify-content: center;
686
687 [class^='am-icon-']^='am-icon-'] {
688 font-size: 26px;
689 }
690 }
691
692 &__error {
693 color: var(--am-c-error);
694 display: flex;
695 align-items: center;
696 gap: 8px;
697 padding: 12px 16px;
698 background-color: var(--am-c-main-bgr);
699 border-radius: 4px;
700 border: 1px solid var(--am-c-error);
701 text-align: center;
702 max-width: 500px;
703 font-size: 14px;
704 [class^='am-icon-']^='am-icon-'] {
705 font-size: 22px;
706 }
707 }
708
709 &__response {
710 &-info {
711 display: flex;
712 flex-direction: column;
713 align-items: center;
714 gap: 4px;
715
716 .am-icon {
717 &-checkmark-circle-full {
718 color: var(--am-c-success);
719 font-size: 38px;
720 }
721 &-close-filled {
722 color: var(--am-c-error);
723 font-size: 38px;
724 }
725 }
726 }
727
728 &-message {
729 font-weight: bold;
730 font-size: 26px;
731 line-height: 1.5;
732 text-align: center;
733 color: var(--am-c-main-text);
734 }
735
736 &-name {
737 font-size: 18px;
738 font-weight: bold;
739 text-align: center;
740 margin-top: 16px;
741 margin-bottom: 8px;
742 color: var(--am-c-main-text);
743 }
744
745 &-item {
746 font-size: 14px;
747 font-weight: bold;
748 color: var(--am-c-main-text);
749
750 span {
751 font-weight: normal;
752 color: var(--am-c-main-text);
753 }
754 }
755 }
756
757 // Collapse for manual input
758 &__collapse {
759 width: 100%;
760 max-width: 500px;
761
762 &-item {
763 width: 100%;
764 }
765 }
766
767 &__form {
768 width: 100%;
769 max-width: 500px;
770 padding: 0 16px;
771
772 .el-form-item {
773 &.is-required {
774 .el-form-item__label {
775 &:before {
776 top: -8px;
777 }
778 }
779 }
780
781 .am-button {
782 width: 100%;
783 }
784 }
785 }
786 }
787
788 @media (max-width: 768px) {
789 .am-cqcs {
790 &__camera-container {
791 max-width: 100%;
792 }
793
794 &__controls {
795 flex-direction: column;
796 align-items: center;
797
798 .el-button {
799 width: 200px;
800 }
801 }
802 }
803 }
804 }
805
806 .amelia-v2-booking #amelia-container {
807 @include qr-code-scanner;
808 }
809 </style>
810