8 changed files with 583 additions and 1511 deletions
			
			
		| @ -1,722 +0,0 @@ | |||
| <!-- QRScannerDialog.vue --> | |||
| <template> | |||
|   <div | |||
|     v-if="visible && !isNativePlatform" | |||
|     class="dialog-overlay z-[60] fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" | |||
|   > | |||
|     <div | |||
|       class="dialog relative bg-white rounded-lg shadow-xl max-w-lg w-full mx-4" | |||
|     > | |||
|       <!-- Header --> | |||
|       <div | |||
|         class="p-4 border-b border-gray-200 flex justify-between items-center" | |||
|       > | |||
|         <div> | |||
|           <h3 class="text-lg font-medium text-gray-900">Scan QR Code</h3> | |||
|           <span class="text-xs text-gray-500">v1.1.0</span> | |||
|         </div> | |||
|         <button | |||
|           class="text-gray-400 hover:text-gray-500" | |||
|           aria-label="Close dialog" | |||
|           @click="close" | |||
|         > | |||
|           <svg | |||
|             class="h-6 w-6" | |||
|             fill="none" | |||
|             viewBox="0 0 24 24" | |||
|             stroke="currentColor" | |||
|           > | |||
|             <path | |||
|               stroke-linecap="round" | |||
|               stroke-linejoin="round" | |||
|               stroke-width="2" | |||
|               d="M6 18L18 6M6 6l12 12" | |||
|             /> | |||
|           </svg> | |||
|         </button> | |||
|       </div> | |||
| 
 | |||
|       <!-- Scanner --> | |||
|       <div class="p-4"> | |||
|         <div | |||
|           v-if="useQRReader && !isNativePlatform" | |||
|           class="relative aspect-square" | |||
|         > | |||
|           <!-- Status Message --> | |||
|           <div | |||
|             class="absolute top-0 left-0 right-0 bg-black bg-opacity-50 text-white text-center py-2 z-10" | |||
|           > | |||
|             <div | |||
|               v-if="isInitializing" | |||
|               class="flex items-center justify-center space-x-2" | |||
|             > | |||
|               <svg | |||
|                 class="animate-spin h-5 w-5 text-white" | |||
|                 xmlns="http://www.w3.org/2000/svg" | |||
|                 fill="none" | |||
|                 viewBox="0 0 24 24" | |||
|               > | |||
|                 <circle | |||
|                   class="opacity-25" | |||
|                   cx="12" | |||
|                   cy="12" | |||
|                   r="10" | |||
|                   stroke="currentColor" | |||
|                   stroke-width="4" | |||
|                 ></circle> | |||
|                 <path | |||
|                   class="opacity-75" | |||
|                   fill="currentColor" | |||
|                   d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 | |||
|           3.042 1.135 5.824 3 7.938l3-2.647z" | |||
|                 ></path> | |||
|               </svg> | |||
|               <span>{{ initializationStatus }}</span> | |||
|             </div> | |||
|             <p | |||
|               v-else-if="isScanning" | |||
|               class="flex items-center justify-center space-x-2" | |||
|             > | |||
|               <span | |||
|                 class="inline-block w-2 h-2 bg-green-500 rounded-full animate-pulse" | |||
|               ></span> | |||
|               <span>Position QR code in the frame</span> | |||
|             </p> | |||
|             <p v-else-if="error" class="text-red-300"> | |||
|               <span class="font-medium">Error:</span> {{ error }} | |||
|             </p> | |||
|             <p v-else class="flex items-center justify-center space-x-2"> | |||
|               <span | |||
|                 class="inline-block w-2 h-2 bg-blue-500 rounded-full" | |||
|               ></span> | |||
|               <span>Ready to scan</span> | |||
|             </p> | |||
|           </div> | |||
| 
 | |||
|           <qrcode-stream | |||
|             :camera="preferredCamera" | |||
|             @decode="onDecode" | |||
|             @init="onInit" | |||
|             @detect="onDetect" | |||
|             @error="onError" | |||
|             @camera-on="onCameraOn" | |||
|             @camera-off="onCameraOff" | |||
|           /> | |||
| 
 | |||
|           <!-- Scanning Frame --> | |||
|           <div | |||
|             class="absolute inset-0 border-2" | |||
|             :class="{ | |||
|               'border-blue-500': !error && !isScanning, | |||
|               'border-green-500 animate-pulse': isScanning, | |||
|               'border-red-500': error, | |||
|             }" | |||
|             style="opacity: 0.5; pointer-events: none" | |||
|           ></div> | |||
| 
 | |||
|           <!-- Debug Info --> | |||
|           <div | |||
|             class="absolute bottom-16 left-0 right-0 bg-black bg-opacity-50 text-white text-xs text-center py-1" | |||
|           > | |||
|             Camera: {{ preferredCamera === "user" ? "Front" : "Back" }} | | |||
|             Status: {{ cameraStatus }} | |||
|           </div> | |||
| 
 | |||
|           <!-- Camera Switch Button --> | |||
|           <button | |||
|             class="absolute bottom-4 right-4 bg-white rounded-full p-2 shadow-lg" | |||
|             title="Switch camera" | |||
|             @click="toggleCamera" | |||
|           > | |||
|             <svg | |||
|               class="h-6 w-6 text-gray-600" | |||
|               fill="none" | |||
|               viewBox="0 0 24 24" | |||
|               stroke="currentColor" | |||
|             > | |||
|               <path | |||
|                 stroke-linecap="round" | |||
|                 stroke-linejoin="round" | |||
|                 stroke-width="2" | |||
|                 d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0  | |||
|         011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" | |||
|               /> | |||
|               <path | |||
|                 stroke-linecap="round" | |||
|                 stroke-linejoin="round" | |||
|                 stroke-width="2" | |||
|                 d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" | |||
|               /> | |||
|             </svg> | |||
|           </button> | |||
|         </div> | |||
|         <div v-else class="text-center py-8"> | |||
|           <p class="text-gray-500"> | |||
|             {{ | |||
|               isNativePlatform | |||
|                 ? "Using native camera scanner..." | |||
|                 : "QR code scanning is not supported in this browser." | |||
|             }} | |||
|           </p> | |||
|           <p v-if="!isNativePlatform" class="text-sm text-gray-400 mt-2"> | |||
|             Please ensure you're using a modern browser with camera access. | |||
|           </p> | |||
|         </div> | |||
|       </div> | |||
| 
 | |||
|       <!-- Error Banner --> | |||
|       <div | |||
|         v-if="error" | |||
|         class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" | |||
|         role="alert" | |||
|       > | |||
|         <strong class="font-bold">Camera Error:</strong> | |||
|         <span class="block sm:inline">{{ error }}</span> | |||
|         <ul class="mt-2 text-sm text-red-600 list-disc list-inside"> | |||
|           <li v-if="error.includes('No camera found')"> | |||
|             Check if your device has a camera and it is enabled. | |||
|           </li> | |||
|           <li v-if="error.includes('denied')"> | |||
|             Allow camera access in your browser settings and reload the page. | |||
|           </li> | |||
|           <li v-if="error.includes('in use')"> | |||
|             Close other applications that may be using the camera. | |||
|           </li> | |||
|           <li v-if="error.includes('HTTPS')"> | |||
|             Ensure you are using a secure (HTTPS) connection. | |||
|           </li> | |||
|           <li | |||
|             v-if=" | |||
|               !error.includes('No camera found') && | |||
|               !error.includes('denied') && | |||
|               !error.includes('in use') && | |||
|               !error.includes('HTTPS') | |||
|             " | |||
|           > | |||
|             Try refreshing the page or using a different browser/device. | |||
|           </li> | |||
|         </ul> | |||
|       </div> | |||
| 
 | |||
|       <!-- Footer --> | |||
|       <div class="p-4 border-t border-gray-200"> | |||
|         <div class="flex flex-col space-y-4"> | |||
|           <!-- Instructions --> | |||
|           <div class="text-sm text-gray-600"> | |||
|             <ul class="list-disc list-inside space-y-1"> | |||
|               <li>Ensure the QR code is well-lit and in focus</li> | |||
|               <li>Hold your device steady</li> | |||
|               <li>The QR code should fit within the scanning frame</li> | |||
|             </ul> | |||
|           </div> | |||
| 
 | |||
|           <!-- Error Message --> | |||
|           <p v-if="error" class="text-red-500 text-sm">{{ error }}</p> | |||
| 
 | |||
|           <!-- Actions --> | |||
|           <div class="flex justify-end space-x-2"> | |||
|             <button | |||
|               v-if="error" | |||
|               class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600" | |||
|               @click="retryScanning" | |||
|             > | |||
|               Retry | |||
|             </button> | |||
|             <button | |||
|               class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200" | |||
|               @click="close" | |||
|             > | |||
|               Cancel | |||
|             </button> | |||
|             <button | |||
|               class="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600" | |||
|               @click="copyLogs" | |||
|             > | |||
|               Copy Debug Logs | |||
|             </button> | |||
|           </div> | |||
|         </div> | |||
|       </div> | |||
|     </div> | |||
|   </div> | |||
|   <div v-if="errorMessage" class="error-message">{{ errorMessage }}</div> | |||
| </template> | |||
| 
 | |||
| <script lang="ts"> | |||
| import { Component, Prop, Vue } from "vue-facing-decorator"; | |||
| import { QrcodeStream } from "vue-qrcode-reader"; | |||
| import { QRScannerOptions } from "@/services/QRScanner/types"; | |||
| import { logger } from "@/utils/logger"; | |||
| import { Capacitor } from "@capacitor/core"; | |||
| import { logCollector } from "@/utils/LogCollector"; | |||
| 
 | |||
| interface ScanProps { | |||
|   onScan: (result: string) => void; | |||
|   onError?: (error: Error) => void; | |||
|   options?: QRScannerOptions; | |||
|   onClose?: () => void; | |||
| } | |||
| 
 | |||
| interface DetectionResult { | |||
|   content?: string; | |||
|   location?: { | |||
|     topLeft: { x: number; y: number }; | |||
|     topRight: { x: number; y: number }; | |||
|     bottomLeft: { x: number; y: number }; | |||
|     bottomRight: { x: number; y: number }; | |||
|   }; | |||
| } | |||
| 
 | |||
| @Component({ | |||
|   components: { | |||
|     QrcodeStream, | |||
|   }, | |||
| }) | |||
| export default class QRScannerDialog extends Vue { | |||
|   @Prop({ type: Function, required: true }) onScan!: ScanProps["onScan"]; | |||
|   @Prop({ type: Function }) onError?: ScanProps["onError"]; | |||
|   @Prop({ type: Object }) options?: ScanProps["options"]; | |||
|   @Prop({ type: Function }) onClose?: ScanProps["onClose"]; | |||
| 
 | |||
|   // Version | |||
|   readonly version = "1.1.0"; | |||
| 
 | |||
|   visible = true; | |||
|   error: string | null = null; | |||
|   useQRReader = __USE_QR_READER__; | |||
|   isNativePlatform = | |||
|     Capacitor.isNativePlatform() || | |||
|     __IS_MOBILE__ || | |||
|     Capacitor.getPlatform() === "android" || | |||
|     Capacitor.getPlatform() === "ios"; | |||
| 
 | |||
|   isInitializing = true; | |||
|   isScanning = false; | |||
|   preferredCamera: "user" | "environment" = "environment"; | |||
|   initializationStatus = "Checking camera access..."; | |||
|   cameraStatus = "Initializing"; | |||
|   errorMessage = ""; | |||
| 
 | |||
|   created() { | |||
|     logger.log("[QRScannerDialog] created"); | |||
|     logger.log("[QRScannerDialog] Props received:", { | |||
|       onScan: typeof this.onScan, | |||
|       onError: typeof this.onError, | |||
|       options: this.options, | |||
|       onClose: typeof this.onClose, | |||
|     }); | |||
|     logger.log("[QRScannerDialog] Initial state:", { | |||
|       visible: this.visible, | |||
|       error: this.error, | |||
|       useQRReader: this.useQRReader, | |||
|       isNativePlatform: this.isNativePlatform, | |||
|       isInitializing: this.isInitializing, | |||
|       isScanning: this.isScanning, | |||
|       preferredCamera: this.preferredCamera, | |||
|       initializationStatus: this.initializationStatus, | |||
|       cameraStatus: this.cameraStatus, | |||
|       errorMessage: this.errorMessage, | |||
|     }); | |||
|     logger.log("QRScannerDialog platform detection:", { | |||
|       capacitorNative: Capacitor.isNativePlatform(), | |||
|       isMobile: __IS_MOBILE__, | |||
|       platform: Capacitor.getPlatform(), | |||
|       useQRReader: this.useQRReader, | |||
|       isNativePlatform: this.isNativePlatform, | |||
|       userAgent: navigator.userAgent, | |||
|       mediaDevices: !!navigator.mediaDevices, | |||
|       getUserMedia: !!( | |||
|         navigator.mediaDevices && navigator.mediaDevices.getUserMedia | |||
|       ), | |||
|     }); | |||
|     if (this.isNativePlatform) { | |||
|       logger.log("Closing QR dialog on native platform"); | |||
|       this.$nextTick(() => this.close()); | |||
|     } | |||
|   } | |||
| 
 | |||
|   mounted() { | |||
|     logger.log("[QRScannerDialog] mounted"); | |||
|     // Timer to warn if no QR code detected after 10 seconds | |||
|     this._scanTimeout = setTimeout(() => { | |||
|       if (!this.isScanning) { | |||
|         logger.warn("[QRScannerDialog] No QR code detected after 10 seconds"); | |||
|       } | |||
|     }, 10000); | |||
|     // Periodic timer to log waiting status every 5 seconds | |||
|     this._waitingInterval = setInterval(() => { | |||
|       if (!this.isScanning && this.cameraStatus === "Active") { | |||
|         logger.log("[QRScannerDialog] Still waiting for QR code detection..."); | |||
|       } | |||
|     }, 5000); | |||
|     logger.log("[QRScannerDialog] Waiting interval started"); | |||
|   } | |||
| 
 | |||
|   beforeUnmount() { | |||
|     if (this._scanTimeout) { | |||
|       clearTimeout(this._scanTimeout); | |||
|       logger.log("[QRScannerDialog] Scan timeout cleared"); | |||
|     } | |||
|     if (this._waitingInterval) { | |||
|       clearInterval(this._waitingInterval); | |||
|       logger.log("[QRScannerDialog] Waiting interval cleared"); | |||
|     } | |||
|     logger.log("[QRScannerDialog] beforeUnmount"); | |||
|   } | |||
| 
 | |||
|   async onInit(promise: Promise<void>): Promise<void> { | |||
|     logger.log("[QRScannerDialog] onInit called"); | |||
|     if (this.isNativePlatform) { | |||
|       logger.log("Closing QR dialog on native platform"); | |||
|       this.$nextTick(() => this.close()); | |||
|       return; | |||
|     } | |||
|     this.isInitializing = true; | |||
|     logger.log("[QRScannerDialog] isInitializing set to", this.isInitializing); | |||
|     this.error = null; | |||
|     this.initializationStatus = "Checking camera access..."; | |||
|     logger.log( | |||
|       "[QRScannerDialog] initializationStatus set to", | |||
|       this.initializationStatus, | |||
|     ); | |||
|     try { | |||
|       if (!navigator.mediaDevices) { | |||
|         logger.log("[QRScannerDialog] Camera API not available"); | |||
|         throw new Error( | |||
|           "Camera API not available. Please ensure you're using HTTPS.", | |||
|         ); | |||
|       } | |||
|       const devices = await navigator.mediaDevices.enumerateDevices(); | |||
|       const videoDevices = devices.filter( | |||
|         (device) => device.kind === "videoinput", | |||
|       ); | |||
|       logger.log("[QRScannerDialog] videoDevices found:", videoDevices.length); | |||
|       if (videoDevices.length === 0) { | |||
|         throw new Error("No camera found on this device"); | |||
|       } | |||
|       this.initializationStatus = "Requesting camera permission..."; | |||
|       logger.log( | |||
|         "[QRScannerDialog] initializationStatus set to", | |||
|         this.initializationStatus, | |||
|       ); | |||
|       try { | |||
|         const stream = await navigator.mediaDevices.getUserMedia({ | |||
|           video: { | |||
|             facingMode: this.preferredCamera, | |||
|             width: { ideal: 1280 }, | |||
|             height: { ideal: 720 }, | |||
|           }, | |||
|         }); | |||
|         stream.getTracks().forEach((track) => track.stop()); | |||
|         this.initializationStatus = "Camera permission granted..."; | |||
|         logger.log( | |||
|           "[QRScannerDialog] initializationStatus set to", | |||
|           this.initializationStatus, | |||
|         ); | |||
|       } catch (permissionError) { | |||
|         const error = permissionError as Error; | |||
|         logger.log( | |||
|           "[QRScannerDialog] Camera permission error:", | |||
|           error.name, | |||
|           error.message, | |||
|         ); | |||
|         if ( | |||
|           error.name === "NotAllowedError" || | |||
|           error.name === "PermissionDeniedError" | |||
|         ) { | |||
|           throw new Error( | |||
|             "Camera access denied. Please grant camera permission and try again.", | |||
|           ); | |||
|         } else if ( | |||
|           error.name === "NotFoundError" || | |||
|           error.name === "DevicesNotFoundError" | |||
|         ) { | |||
|           throw new Error( | |||
|             "No camera found. Please ensure your device has a camera.", | |||
|           ); | |||
|         } else if ( | |||
|           error.name === "NotReadableError" || | |||
|           error.name === "TrackStartError" | |||
|         ) { | |||
|           throw new Error("Camera is in use by another application."); | |||
|         } else { | |||
|           throw new Error(`Camera error: ${error.message}`); | |||
|         } | |||
|       } | |||
|       this.initializationStatus = "Starting QR scanner..."; | |||
|       logger.log( | |||
|         "[QRScannerDialog] initializationStatus set to", | |||
|         this.initializationStatus, | |||
|       ); | |||
|       await promise; | |||
|       this.isInitializing = false; | |||
|       this.cameraStatus = "Ready"; | |||
|       logger.log("[QRScannerDialog] QR scanner initialized successfully"); | |||
|       logger.log("[QRScannerDialog] cameraStatus set to", this.cameraStatus); | |||
|     } catch (error) { | |||
|       const wrappedError = | |||
|         error instanceof Error ? error : new Error(String(error)); | |||
|       this.error = wrappedError.message; | |||
|       this.cameraStatus = "Error"; | |||
|       logger.log( | |||
|         "[QRScannerDialog] Error initializing QR scanner:", | |||
|         wrappedError.message, | |||
|       ); | |||
|       logger.log("[QRScannerDialog] cameraStatus set to", this.cameraStatus); | |||
|       if (this.onError) { | |||
|         this.onError(wrappedError); | |||
|       } | |||
|     } finally { | |||
|       this.isInitializing = false; | |||
|       logger.log( | |||
|         "[QRScannerDialog] isInitializing set to", | |||
|         this.isInitializing, | |||
|       ); | |||
|     } | |||
|   } | |||
| 
 | |||
|   onCameraOn(): void { | |||
|     this.cameraStatus = "Active"; | |||
|     logger.log("[QRScannerDialog] Camera turned on successfully"); | |||
|     logger.log("[QRScannerDialog] cameraStatus set to", this.cameraStatus); | |||
|   } | |||
| 
 | |||
|   onCameraOff(): void { | |||
|     this.cameraStatus = "Off"; | |||
|     logger.log("[QRScannerDialog] Camera turned off"); | |||
|     logger.log("[QRScannerDialog] cameraStatus set to", this.cameraStatus); | |||
|   } | |||
| 
 | |||
|   onDetect(result: DetectionResult | Promise<DetectionResult>): void { | |||
|     const ts = new Date().toISOString(); | |||
|     logger.log(`[QRScannerDialog] onDetect called at ${ts} with`, result); | |||
|     this.isScanning = true; | |||
|     this.cameraStatus = "Detecting"; | |||
|     logger.log("[QRScannerDialog] isScanning set to", this.isScanning); | |||
|     logger.log("[QRScannerDialog] cameraStatus set to", this.cameraStatus); | |||
|     const processResult = (detection: DetectionResult | DetectionResult[]) => { | |||
|       try { | |||
|         logger.log( | |||
|           `[QRScannerDialog] onDetect exit at ${new Date().toISOString()} with detection:`, | |||
|           detection, | |||
|         ); | |||
|         // Fallback: If detection is an array, check the first element | |||
|         let rawValue: string | undefined; | |||
|         if ( | |||
|           Array.isArray(detection) && | |||
|           detection.length > 0 && | |||
|           "rawValue" in detection[0] | |||
|         ) { | |||
|           rawValue = detection[0].rawValue; | |||
|         } else if ( | |||
|           detection && | |||
|           typeof detection === "object" && | |||
|           "rawValue" in detection && | |||
|           detection.rawValue | |||
|         ) { | |||
|           rawValue = (detection as unknown).rawValue; | |||
|         } | |||
|         if (rawValue) { | |||
|           logger.log( | |||
|             "[QRScannerDialog] Fallback: Detected rawValue, treating as scan:", | |||
|             rawValue, | |||
|           ); | |||
|           this.isInitializing = false; | |||
|           this.initializationStatus = "QR code captured!"; | |||
|           this.onScan(rawValue); | |||
|           try { | |||
|             logger.log("[QRScannerDialog] About to call close() after scan"); | |||
|             this.close(); | |||
|             logger.log( | |||
|               "[QRScannerDialog] close() called successfully after scan", | |||
|             ); | |||
|           } catch (err) { | |||
|             logger.error("[QRScannerDialog] Error calling close():", err); | |||
|           } | |||
|         } | |||
|       } catch (error) { | |||
|         this.handleError(error); | |||
|       } finally { | |||
|         this.isScanning = false; | |||
|         this.cameraStatus = "Active"; | |||
|         logger.log("[QRScannerDialog] isScanning set to", this.isScanning); | |||
|         logger.log("[QRScannerDialog] cameraStatus set to", this.cameraStatus); | |||
|       } | |||
|     }; | |||
|     if (result instanceof Promise) { | |||
|       result | |||
|         .then(processResult) | |||
|         .catch((error: Error) => this.handleError(error)) | |||
|         .finally(() => { | |||
|           this.isScanning = false; | |||
|           this.cameraStatus = "Active"; | |||
|           logger.log("[QRScannerDialog] isScanning set to", this.isScanning); | |||
|           logger.log( | |||
|             "[QRScannerDialog] cameraStatus set to", | |||
|             this.cameraStatus, | |||
|           ); | |||
|         }); | |||
|     } else { | |||
|       processResult(result); | |||
|     } | |||
|   } | |||
| 
 | |||
|   private handleError(error: unknown): void { | |||
|     const wrappedError = | |||
|       error instanceof Error ? error : new Error(String(error)); | |||
|     this.error = wrappedError.message; | |||
|     this.cameraStatus = "Error"; | |||
|     logger.log("[QRScannerDialog] handleError:", wrappedError.message); | |||
|     logger.log("[QRScannerDialog] cameraStatus set to", this.cameraStatus); | |||
|     if (this.onError) { | |||
|       this.onError(wrappedError); | |||
|     } | |||
|   } | |||
| 
 | |||
|   onDecode(result: string): void { | |||
|     const ts = new Date().toISOString(); | |||
|     logger.log( | |||
|       `[QRScannerDialog] onDecode called at ${ts} with result:`, | |||
|       result, | |||
|     ); | |||
|     try { | |||
|       this.isInitializing = false; | |||
|       this.initializationStatus = "QR code captured!"; | |||
|       logger.log( | |||
|         "[QRScannerDialog] UI state updated after scan: isInitializing set to", | |||
|         this.isInitializing, | |||
|         ", initializationStatus set to", | |||
|         this.initializationStatus, | |||
|       ); | |||
|       this.onScan(result); | |||
|       this.close(); | |||
|       logger.log( | |||
|         `[QRScannerDialog] onDecode exit at ${new Date().toISOString()}`, | |||
|       ); | |||
|     } catch (error) { | |||
|       this.handleError(error); | |||
|     } | |||
|   } | |||
| 
 | |||
|   toggleCamera(): void { | |||
|     const prevCamera = this.preferredCamera; | |||
|     this.preferredCamera = | |||
|       this.preferredCamera === "user" ? "environment" : "user"; | |||
|     logger.log( | |||
|       "[QRScannerDialog] toggleCamera from", | |||
|       prevCamera, | |||
|       "to", | |||
|       this.preferredCamera, | |||
|     ); | |||
|     logger.log( | |||
|       "[QRScannerDialog] preferredCamera set to", | |||
|       this.preferredCamera, | |||
|     ); | |||
|   } | |||
| 
 | |||
|   retryScanning(): void { | |||
|     logger.log("[QRScannerDialog] retryScanning called"); | |||
|     this.error = null; | |||
|     this.isInitializing = true; | |||
|     logger.log("[QRScannerDialog] isInitializing set to", this.isInitializing); | |||
|     logger.log("[QRScannerDialog] Scanning re-initialized"); | |||
|   } | |||
| 
 | |||
|   close = async (): Promise<void> => { | |||
|     logger.log("[QRScannerDialog] close called"); | |||
|     this.visible = false; | |||
|     logger.log("[QRScannerDialog] visible set to", this.visible); | |||
|     // Notify parent/service | |||
|     if (typeof this.onClose === "function") { | |||
|       logger.log("[QRScannerDialog] Calling onClose prop"); | |||
|       this.onClose(); | |||
|     } | |||
|     await this.$nextTick(); | |||
|     if (this.$el && this.$el.parentNode) { | |||
|       this.$el.parentNode.removeChild(this.$el); | |||
|       logger.log("[QRScannerDialog] Dialog element removed from DOM"); | |||
|     } else { | |||
|       logger.log("[QRScannerDialog] Dialog element NOT removed from DOM"); | |||
|     } | |||
|   }; | |||
| 
 | |||
|   onScanDetect(promisedResult) { | |||
|     const ts = new Date().toISOString(); | |||
|     logger.log( | |||
|       `[QRScannerDialog] onScanDetect called at ${ts} with`, | |||
|       promisedResult, | |||
|     ); | |||
|     promisedResult | |||
|       .then((result) => { | |||
|         logger.log( | |||
|           `[QRScannerDialog] onScanDetect exit at ${new Date().toISOString()} with result:`, | |||
|           result, | |||
|         ); | |||
|         this.onScan(result); | |||
|       }) | |||
|       .catch((error) => { | |||
|         logger.error( | |||
|           `[QRScannerDialog] onScanDetect error at ${new Date().toISOString()}:`, | |||
|           error, | |||
|         ); | |||
|         this.errorMessage = error.message || "Scan error"; | |||
|         if (this.onError) this.onError(error); | |||
|       }); | |||
|   } | |||
| 
 | |||
|   onScanError(error) { | |||
|     const ts = new Date().toISOString(); | |||
|     logger.error(`[QRScannerDialog] onScanError called at ${ts}:`, error); | |||
|     this.errorMessage = error.message || "Camera error"; | |||
|     if (this.onError) this.onError(error); | |||
|   } | |||
| 
 | |||
|   async startMobileScan() { | |||
|     try { | |||
|       logger.log("[QRScannerDialog] startMobileScan called"); | |||
|       const scanner = QRScannerFactory.getInstance(); | |||
|       await scanner.startScan(); | |||
|     } catch (error) { | |||
|       logger.error("[QRScannerDialog] Error starting mobile scan:", error); | |||
|       if (this.onError) this.onError(error); | |||
|     } | |||
|   } | |||
| 
 | |||
|   async copyLogs() { | |||
|     logger.log("[QRScannerDialog] copyLogs called"); | |||
|     try { | |||
|       await navigator.clipboard.writeText(logCollector.getLogs()); | |||
|       alert("Logs copied to clipboard!"); | |||
|     } catch (e) { | |||
|       alert("Failed to copy logs: " + (e instanceof Error ? e.message : e)); | |||
|     } | |||
|   } | |||
| } | |||
| </script> | |||
| 
 | |||
| <style scoped> | |||
| .dialog-overlay { | |||
|   backdrop-filter: blur(4px); | |||
| } | |||
| 
 | |||
| .qrcode-stream { | |||
|   width: 100%; | |||
|   height: 100%; | |||
| } | |||
| 
 | |||
| @keyframes pulse { | |||
|   0% { | |||
|     opacity: 0.5; | |||
|   } | |||
|   50% { | |||
|     opacity: 0.75; | |||
|   } | |||
|   100% { | |||
|     opacity: 0.5; | |||
|   } | |||
| } | |||
| 
 | |||
| .animate-pulse { | |||
|   animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; | |||
| } | |||
| </style> | |||
| @ -1,15 +0,0 @@ | |||
| export interface QRScannerListener { | |||
|   onScan: (result: string) => void; | |||
|   onError: (error: Error) => void; | |||
| } | |||
| 
 | |||
| export interface QRScannerService { | |||
|   checkPermissions(): Promise<boolean>; | |||
|   requestPermissions(): Promise<boolean>; | |||
|   isSupported(): Promise<boolean>; | |||
|   startScan(): Promise<void>; | |||
|   stopScan(): Promise<void>; | |||
|   addListener(listener: QRScannerListener): void; | |||
|   cleanup(): Promise<void>; | |||
|   onStream(callback: (stream: MediaStream | null) => void): void; | |||
| } | |||
| @ -1,263 +0,0 @@ | |||
| import { createApp, App } from "vue"; | |||
| import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; | |||
| import QRScannerDialog from "@/components/QRScanner/QRScannerDialog.vue"; | |||
| import { logger } from "@/utils/logger"; | |||
| 
 | |||
| export class WebDialogQRScanner implements QRScannerService { | |||
|   private dialogInstance: App | null = null; | |||
|   private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null; | |||
|   private scanListener: ScanListener | null = null; | |||
|   private isScanning = false; | |||
|   private container: HTMLElement | null = null; | |||
|   private sessionId: number | null = null; | |||
|   private failsafeTimeout: unknown = null; | |||
| 
 | |||
|   constructor(private options?: QRScannerOptions) {} | |||
| 
 | |||
|   async checkPermissions(): Promise<boolean> { | |||
|     try { | |||
|       logger.log("[QRScanner] Checking camera permissions..."); | |||
|       const permissions = await navigator.permissions.query({ | |||
|         name: "camera" as PermissionName, | |||
|       }); | |||
|       logger.log("[QRScanner] Permission state:", permissions.state); | |||
|       return permissions.state === "granted"; | |||
|     } catch (error) { | |||
|       logger.error("[QRScanner] Error checking camera permissions:", error); | |||
|       return false; | |||
|     } | |||
|   } | |||
| 
 | |||
|   async requestPermissions(): Promise<boolean> { | |||
|     try { | |||
|       // First check if we have any video devices
 | |||
|       const devices = await navigator.mediaDevices.enumerateDevices(); | |||
|       const videoDevices = devices.filter( | |||
|         (device) => device.kind === "videoinput", | |||
|       ); | |||
| 
 | |||
|       if (videoDevices.length === 0) { | |||
|         logger.error("No video devices found"); | |||
|         throw new Error("No camera found on this device"); | |||
|       } | |||
| 
 | |||
|       // Try to get a stream with specific constraints
 | |||
|       const stream = await navigator.mediaDevices.getUserMedia({ | |||
|         video: { | |||
|           facingMode: "environment", | |||
|           width: { ideal: 1280 }, | |||
|           height: { ideal: 720 }, | |||
|         }, | |||
|       }); | |||
| 
 | |||
|       // Stop the test stream immediately
 | |||
|       stream.getTracks().forEach((track) => track.stop()); | |||
|       return true; | |||
|     } catch (error) { | |||
|       const wrappedError = | |||
|         error instanceof Error ? error : new Error(String(error)); | |||
|       logger.error("Error requesting camera permissions:", { | |||
|         error: wrappedError.message, | |||
|         stack: wrappedError.stack, | |||
|         name: wrappedError.name, | |||
|       }); | |||
| 
 | |||
|       // Provide more specific error messages
 | |||
|       if ( | |||
|         wrappedError.name === "NotFoundError" || | |||
|         wrappedError.name === "DevicesNotFoundError" | |||
|       ) { | |||
|         throw new Error("No camera found on this device"); | |||
|       } else if ( | |||
|         wrappedError.name === "NotAllowedError" || | |||
|         wrappedError.name === "PermissionDeniedError" | |||
|       ) { | |||
|         throw new Error( | |||
|           "Camera access denied. Please grant camera permission and try again", | |||
|         ); | |||
|       } else if ( | |||
|         wrappedError.name === "NotReadableError" || | |||
|         wrappedError.name === "TrackStartError" | |||
|       ) { | |||
|         throw new Error("Camera is in use by another application"); | |||
|       } else { | |||
|         throw new Error(`Camera error: ${wrappedError.message}`); | |||
|       } | |||
|     } | |||
|   } | |||
| 
 | |||
|   async isSupported(): Promise<boolean> { | |||
|     try { | |||
|       // Check for secure context first
 | |||
|       if (!window.isSecureContext) { | |||
|         logger.warn("Camera access requires HTTPS (secure context)"); | |||
|         return false; | |||
|       } | |||
| 
 | |||
|       // Check for camera API support
 | |||
|       if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |||
|         logger.warn("Camera API not supported in this browser"); | |||
|         return false; | |||
|       } | |||
| 
 | |||
|       // Check if we have any video devices
 | |||
|       const devices = await navigator.mediaDevices.enumerateDevices(); | |||
|       const hasVideoDevices = devices.some( | |||
|         (device) => device.kind === "videoinput", | |||
|       ); | |||
| 
 | |||
|       if (!hasVideoDevices) { | |||
|         logger.warn("No video devices found"); | |||
|         return false; | |||
|       } | |||
| 
 | |||
|       return true; | |||
|     } catch (error) { | |||
|       logger.error("Error checking camera support:", { | |||
|         error: error instanceof Error ? error.message : String(error), | |||
|         stack: error instanceof Error ? error.stack : undefined, | |||
|       }); | |||
|       return false; | |||
|     } | |||
|   } | |||
| 
 | |||
|   async startScan(): Promise<void> { | |||
|     if (this.isScanning) { | |||
|       return; | |||
|     } | |||
| 
 | |||
|     try { | |||
|       this.isScanning = true; | |||
|       this.sessionId = Date.now(); | |||
|       logger.log( | |||
|         `[WebDialogQRScanner] Opening dialog, session: ${this.sessionId}`, | |||
|       ); | |||
| 
 | |||
|       // Create and mount dialog component
 | |||
|       this.container = document.createElement("div"); | |||
|       document.body.appendChild(this.container); | |||
| 
 | |||
|       this.dialogInstance = createApp(QRScannerDialog, { | |||
|         onScan: (result: string) => { | |||
|           if (this.scanListener) { | |||
|             this.scanListener.onScan(result); | |||
|           } | |||
|         }, | |||
|         onError: (error: Error) => { | |||
|           if (this.scanListener?.onError) { | |||
|             this.scanListener.onError(error); | |||
|           } | |||
|         }, | |||
|         onClose: () => { | |||
|           logger.log( | |||
|             `[WebDialogQRScanner] onClose received from dialog, session: ${this.sessionId}`, | |||
|           ); | |||
|           this.stopScan("dialog onClose"); | |||
|         }, | |||
|         options: this.options, | |||
|         sessionId: this.sessionId, | |||
|       }); | |||
| 
 | |||
|       this.dialogComponent = this.dialogInstance.mount( | |||
|         this.container, | |||
|       ) as InstanceType<typeof QRScannerDialog>; | |||
| 
 | |||
|       // Failsafe: force cleanup after 60s if dialog is still open
 | |||
|       this.failsafeTimeout = setTimeout(() => { | |||
|         if (this.isScanning) { | |||
|           logger.warn( | |||
|             `[WebDialogQRScanner] Failsafe triggered, forcing cleanup for session: ${this.sessionId}`, | |||
|           ); | |||
|           this.stopScan("failsafe timeout"); | |||
|         } | |||
|       }, 60000); | |||
|       logger.log( | |||
|         `[WebDialogQRScanner] Failsafe timeout set for session: ${this.sessionId}`, | |||
|       ); | |||
|     } catch (error) { | |||
|       this.isScanning = false; | |||
|       const wrappedError = | |||
|         error instanceof Error ? error : new Error(String(error)); | |||
|       if (this.scanListener?.onError) { | |||
|         this.scanListener.onError(wrappedError); | |||
|       } | |||
|       logger.error("Error starting scan:", wrappedError); | |||
|       this.cleanupContainer(); | |||
|       throw wrappedError; | |||
|     } | |||
|   } | |||
| 
 | |||
|   async stopScan(reason: string = "manual"): Promise<void> { | |||
|     if (!this.isScanning) { | |||
|       return; | |||
|     } | |||
| 
 | |||
|     try { | |||
|       logger.log( | |||
|         `[WebDialogQRScanner] stopScan called, reason: ${reason}, session: ${this.sessionId}`, | |||
|       ); | |||
|       if (this.dialogComponent) { | |||
|         await this.dialogComponent.close(); | |||
|         logger.log( | |||
|           `[WebDialogQRScanner] dialogComponent.close() called, session: ${this.sessionId}`, | |||
|         ); | |||
|       } | |||
|       if (this.dialogInstance) { | |||
|         this.dialogInstance.unmount(); | |||
|         logger.log( | |||
|           `[WebDialogQRScanner] dialogInstance.unmount() called, session: ${this.sessionId}`, | |||
|         ); | |||
|       } | |||
|     } catch (error) { | |||
|       const wrappedError = | |||
|         error instanceof Error ? error : new Error(String(error)); | |||
|       logger.error("Error stopping scan:", wrappedError); | |||
|       throw wrappedError; | |||
|     } finally { | |||
|       this.isScanning = false; | |||
|       if (this.failsafeTimeout) { | |||
|         clearTimeout(this.failsafeTimeout); | |||
|         this.failsafeTimeout = null; | |||
|         logger.log( | |||
|           `[WebDialogQRScanner] Failsafe timeout cleared, session: ${this.sessionId}`, | |||
|         ); | |||
|       } | |||
|       this.cleanupContainer(); | |||
|     } | |||
|   } | |||
| 
 | |||
|   addListener(listener: ScanListener): void { | |||
|     this.scanListener = listener; | |||
|   } | |||
| 
 | |||
|   private cleanupContainer(): void { | |||
|     if (this.container && this.container.parentNode) { | |||
|       this.container.parentNode.removeChild(this.container); | |||
|       logger.log( | |||
|         `[WebDialogQRScanner] Dialog container removed from DOM, session: ${this.sessionId}`, | |||
|       ); | |||
|     } else { | |||
|       logger.log( | |||
|         `[WebDialogQRScanner] Dialog container NOT removed from DOM, session: ${this.sessionId}`, | |||
|       ); | |||
|     } | |||
|     this.container = null; | |||
|   } | |||
| 
 | |||
|   async cleanup(): Promise<void> { | |||
|     try { | |||
|       await this.stopScan("cleanup"); | |||
|     } catch (error) { | |||
|       const wrappedError = | |||
|         error instanceof Error ? error : new Error(String(error)); | |||
|       logger.error("Error during cleanup:", wrappedError); | |||
|       throw wrappedError; | |||
|     } finally { | |||
|       this.dialogComponent = null; | |||
|       this.dialogInstance = null; | |||
|       this.scanListener = null; | |||
|       this.cleanupContainer(); | |||
|       this.sessionId = null; | |||
|     } | |||
|   } | |||
| } | |||
					Loading…
					
					
				
		Reference in new issue