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