From 7a9871f43fb51cfb0a42092fb55bec9209514811 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 28 Jun 2026 21:40:54 +0000 Subject: [PATCH] Add tablet camera scanning for employee badge QR on kiosk. Uses html5-qrcode with rear camera preview on Scan badge, keeps external scanner fallback, and still requires PIN after a successful scan. Co-authored-by: Cursor --- package-lock.json | 8 +- package.json | 1 + resources/js/kiosk-flow.js | 118 +++++++++++++++++- .../views/frontdesk/kiosk/index.blade.php | 22 +++- 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index af2696a..c5d1065 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "ladill-crm", + "name": "ladill-frontdesk", "lockfileVersion": 3, "requires": true, "packages": { @@ -8,6 +8,7 @@ "@alpinejs/collapse": "^3.15.12", "@tailwindcss/forms": "^0.5.11", "alpinejs": "^3.15.12", + "html5-qrcode": "^2.3.8", "qr-code-styling": "^1.9.2", "qrcode-generator": "^2.0.4" }, @@ -1736,6 +1737,11 @@ "node": ">= 0.4" } }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==" + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", diff --git a/package.json b/package.json index f594a7f..15dfc22 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@alpinejs/collapse": "^3.15.12", "@tailwindcss/forms": "^0.5.11", "alpinejs": "^3.15.12", + "html5-qrcode": "^2.3.8", "qr-code-styling": "^1.9.2", "qrcode-generator": "^2.0.4" } diff --git a/resources/js/kiosk-flow.js b/resources/js/kiosk-flow.js index f3f0635..8457167 100644 --- a/resources/js/kiosk-flow.js +++ b/resources/js/kiosk-flow.js @@ -9,6 +9,10 @@ export function registerKioskFlow(Alpine) { stepOutReasons: config.stepOutReasons || [], staffIdentifyMode: 'code', staffForm: { employee_code: '', qr_token: '', pin: '' }, + staffQrScanner: null, + staffQrScanning: false, + staffQrScanned: false, + staffQrError: '', staffEmployee: null, staffMessage: '', staffBranchWarning: '', @@ -175,7 +179,10 @@ export function registerKioskFlow(Alpine) { } }, - goBack() { + async goBack() { + if (this.step === 'staff_identify') { + await this.stopStaffQrScanner(true); + } if (this.step === 'type') { this.step = this.employeeKioskEnabled ? 'choose' : 'welcome'; this.typeIndex = 0; @@ -204,6 +211,7 @@ export function registerKioskFlow(Alpine) { }, reset() { + this.stopStaffQrScanner(true); this.stopCamera(); this.step = 'welcome'; this.formStep = 1; @@ -215,6 +223,8 @@ export function registerKioskFlow(Alpine) { this.staffBranchWarning = ''; this.staffIdentifyMode = 'code'; this.staffForm = { employee_code: '', qr_token: '', pin: '' }; + this.staffQrScanned = false; + this.staffQrError = ''; this.staffStepOut = { step_out_reason: 'lunch', destination: '', notes: '', expected_return_at: '' }; this.form = { full_name: '', company: '', phone: '', email: '', host_id: '', @@ -239,13 +249,118 @@ export function registerKioskFlow(Alpine) { }, startStaffFlow() { + this.stopStaffQrScanner(true); this.step = 'staff_identify'; this.errorMessage = ''; this.staffIdentifyMode = 'code'; this.staffForm = { employee_code: '', qr_token: '', pin: '' }; + this.staffQrScanned = false; + this.staffQrError = ''; this.resetTimer(); }, + async setStaffIdentifyMode(mode) { + if (this.staffIdentifyMode === mode) { + return; + } + await this.stopStaffQrScanner(true); + this.staffIdentifyMode = mode; + this.staffQrError = ''; + if (mode === 'badge') { + await this.$nextTick(); + await this.startStaffQrScanner(); + } + }, + + parseEmployeeQr(text) { + const trimmed = String(text || '').trim(); + if (trimmed === '') { + return null; + } + const urlMatch = trimmed.match(/\/eq\/([A-Za-z0-9]+)/); + if (urlMatch) { + return urlMatch[1]; + } + if (/^[A-Za-z0-9]{16,64}$/.test(trimmed)) { + return trimmed; + } + + return null; + }, + + async startStaffQrScanner() { + if (this.staffQrScanning || this.staffQrScanned || this.staffIdentifyMode !== 'badge') { + return; + } + + this.staffQrError = ''; + try { + const { Html5Qrcode } = await import('html5-qrcode'); + await this.$nextTick(); + + if (! document.getElementById('staff-qr-reader')) { + return; + } + + if (this.staffQrScanner) { + await this.stopStaffQrScanner(false); + } + + this.staffQrScanner = new Html5Qrcode('staff-qr-reader'); + await this.staffQrScanner.start( + { facingMode: 'environment' }, + { fps: 10, qrbox: { width: 260, height: 260 } }, + (decodedText) => this.handleStaffBadgeScan(decodedText), + () => {}, + ); + this.staffQrScanning = true; + } catch (e) { + this.staffQrError = 'Camera unavailable. Use an external scanner or switch to employee code.'; + console.error('Staff QR scanner failed', e); + } + }, + + async handleStaffBadgeScan(decodedText) { + if (! this.parseEmployeeQr(decodedText)) { + return; + } + + this.staffForm.qr_token = decodedText.trim(); + this.staffQrScanned = true; + this.errorMessage = ''; + await this.stopStaffQrScanner(false); + this.resetTimer(); + }, + + async rescanStaffBadge() { + this.staffQrScanned = false; + this.staffForm.qr_token = ''; + this.staffQrError = ''; + await this.$nextTick(); + await this.startStaffQrScanner(); + }, + + async stopStaffQrScanner(clearScanned = false) { + if (this.staffQrScanner) { + try { + if (this.staffQrScanning) { + await this.staffQrScanner.stop(); + } + await this.staffQrScanner.clear(); + } catch (e) { + // Scanner may already be stopped when leaving the step. + } + this.staffQrScanner = null; + this.staffQrScanning = false; + } + + if (clearScanned) { + this.staffQrScanned = false; + this.staffForm.qr_token = ''; + this.staffQrError = ''; + } + }, + staffCredentials() { const body = { pin: this.staffForm.pin }; if (this.staffIdentifyMode === 'badge' && this.staffForm.qr_token.trim()) { @@ -298,6 +413,7 @@ export function registerKioskFlow(Alpine) { return; } try { + await this.stopStaffQrScanner(false); const data = await this.staffPost('identify'); if (!data) return; this.staffEmployee = data.employee; diff --git a/resources/views/frontdesk/kiosk/index.blade.php b/resources/views/frontdesk/kiosk/index.blade.php index 471b1e0..0d1a756 100644 --- a/resources/views/frontdesk/kiosk/index.blade.php +++ b/resources/views/frontdesk/kiosk/index.blade.php @@ -31,6 +31,8 @@ 50% { transform: scale(1.02); box-shadow: 0 24px 48px -10px rgb(79 70 229 / 0.55); } } .kiosk-tap-btn { animation: kiosk-tap-pulse 2.4s ease-in-out infinite; } + #staff-qr-reader { min-height: 280px; } + #staff-qr-reader video { border-radius: 0.75rem; width: 100% !important; } @@ -287,18 +289,30 @@

Employee sign-in

-

Enter your employee code and PIN, or scan your badge.

+

Enter your employee code and PIN, or scan your badge with the camera.

- - + +