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