Add tablet camera scanning for employee badge QR on kiosk.
Deploy Ladill Frontdesk / deploy (push) Successful in 36s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-28 21:40:54 +00:00
co-authored by Cursor
parent cdd675d247
commit 7a9871f43f
4 changed files with 143 additions and 6 deletions
+7 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "ladill-crm", "name": "ladill-frontdesk",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -8,6 +8,7 @@
"@alpinejs/collapse": "^3.15.12", "@alpinejs/collapse": "^3.15.12",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"alpinejs": "^3.15.12", "alpinejs": "^3.15.12",
"html5-qrcode": "^2.3.8",
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",
"qrcode-generator": "^2.0.4" "qrcode-generator": "^2.0.4"
}, },
@@ -1736,6 +1737,11 @@
"node": ">= 0.4" "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": { "node_modules/https-proxy-agent": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@alpinejs/collapse": "^3.15.12", "@alpinejs/collapse": "^3.15.12",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"alpinejs": "^3.15.12", "alpinejs": "^3.15.12",
"html5-qrcode": "^2.3.8",
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",
"qrcode-generator": "^2.0.4" "qrcode-generator": "^2.0.4"
} }
+117 -1
View File
@@ -9,6 +9,10 @@ export function registerKioskFlow(Alpine) {
stepOutReasons: config.stepOutReasons || [], stepOutReasons: config.stepOutReasons || [],
staffIdentifyMode: 'code', staffIdentifyMode: 'code',
staffForm: { employee_code: '', qr_token: '', pin: '' }, staffForm: { employee_code: '', qr_token: '', pin: '' },
staffQrScanner: null,
staffQrScanning: false,
staffQrScanned: false,
staffQrError: '',
staffEmployee: null, staffEmployee: null,
staffMessage: '', staffMessage: '',
staffBranchWarning: '', 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') { if (this.step === 'type') {
this.step = this.employeeKioskEnabled ? 'choose' : 'welcome'; this.step = this.employeeKioskEnabled ? 'choose' : 'welcome';
this.typeIndex = 0; this.typeIndex = 0;
@@ -204,6 +211,7 @@ export function registerKioskFlow(Alpine) {
}, },
reset() { reset() {
this.stopStaffQrScanner(true);
this.stopCamera(); this.stopCamera();
this.step = 'welcome'; this.step = 'welcome';
this.formStep = 1; this.formStep = 1;
@@ -215,6 +223,8 @@ export function registerKioskFlow(Alpine) {
this.staffBranchWarning = ''; this.staffBranchWarning = '';
this.staffIdentifyMode = 'code'; this.staffIdentifyMode = 'code';
this.staffForm = { employee_code: '', qr_token: '', pin: '' }; this.staffForm = { employee_code: '', qr_token: '', pin: '' };
this.staffQrScanned = false;
this.staffQrError = '';
this.staffStepOut = { step_out_reason: 'lunch', destination: '', notes: '', expected_return_at: '' }; this.staffStepOut = { step_out_reason: 'lunch', destination: '', notes: '', expected_return_at: '' };
this.form = { this.form = {
full_name: '', company: '', phone: '', email: '', host_id: '', full_name: '', company: '', phone: '', email: '', host_id: '',
@@ -239,13 +249,118 @@ export function registerKioskFlow(Alpine) {
}, },
startStaffFlow() { startStaffFlow() {
this.stopStaffQrScanner(true);
this.step = 'staff_identify'; this.step = 'staff_identify';
this.errorMessage = ''; this.errorMessage = '';
this.staffIdentifyMode = 'code'; this.staffIdentifyMode = 'code';
this.staffForm = { employee_code: '', qr_token: '', pin: '' }; this.staffForm = { employee_code: '', qr_token: '', pin: '' };
this.staffQrScanned = false;
this.staffQrError = '';
this.resetTimer(); 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() { staffCredentials() {
const body = { pin: this.staffForm.pin }; const body = { pin: this.staffForm.pin };
if (this.staffIdentifyMode === 'badge' && this.staffForm.qr_token.trim()) { if (this.staffIdentifyMode === 'badge' && this.staffForm.qr_token.trim()) {
@@ -298,6 +413,7 @@ export function registerKioskFlow(Alpine) {
return; return;
} }
try { try {
await this.stopStaffQrScanner(false);
const data = await this.staffPost('identify'); const data = await this.staffPost('identify');
if (!data) return; if (!data) return;
this.staffEmployee = data.employee; this.staffEmployee = data.employee;
@@ -31,6 +31,8 @@
50% { transform: scale(1.02); box-shadow: 0 24px 48px -10px rgb(79 70 229 / 0.55); } 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; } .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; }
</style> </style>
</head> </head>
<body class="min-h-screen bg-slate-100 font-sans text-slate-900 antialiased" x-data="kioskFlow" x-init="startTimer()"> <body class="min-h-screen bg-slate-100 font-sans text-slate-900 antialiased" x-data="kioskFlow" x-init="startTimer()">
@@ -287,18 +289,30 @@
</div> </div>
<div class="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-6 shadow-sm"> <div class="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-2xl font-bold text-slate-900">Employee sign-in</h2> <h2 class="text-2xl font-bold text-slate-900">Employee sign-in</h2>
<p class="mt-1 text-sm text-slate-500">Enter your employee code and PIN, or scan your badge.</p> <p class="mt-1 text-sm text-slate-500">Enter your employee code and PIN, or scan your badge with the camera.</p>
<p x-show="errorMessage" x-cloak class="mt-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMessage"></p> <p x-show="errorMessage" x-cloak class="mt-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMessage"></p>
<div class="mt-4 flex gap-2 text-sm"> <div class="mt-4 flex gap-2 text-sm">
<button type="button" @click="staffIdentifyMode = 'code'" :class="staffIdentifyMode === 'code' ? 'bg-indigo-100 text-indigo-800' : 'bg-slate-100 text-slate-600'" class="rounded-lg px-3 py-1.5 font-medium">Code</button> <button type="button" @click="setStaffIdentifyMode('code')" :class="staffIdentifyMode === 'code' ? 'bg-indigo-100 text-indigo-800' : 'bg-slate-100 text-slate-600'" class="rounded-lg px-3 py-1.5 font-medium">Code</button>
<button type="button" @click="staffIdentifyMode = 'badge'" :class="staffIdentifyMode === 'badge' ? 'bg-indigo-100 text-indigo-800' : 'bg-slate-100 text-slate-600'" class="rounded-lg px-3 py-1.5 font-medium">Badge scan</button> <button type="button" @click="setStaffIdentifyMode('badge')" :class="staffIdentifyMode === 'badge' ? 'bg-indigo-100 text-indigo-800' : 'bg-slate-100 text-slate-600'" class="rounded-lg px-3 py-1.5 font-medium">Scan badge</button>
</div> </div>
<div class="mt-6 space-y-4"> <div class="mt-6 space-y-4">
<template x-if="staffIdentifyMode === 'code'"> <template x-if="staffIdentifyMode === 'code'">
<input type="text" x-model="staffForm.employee_code" placeholder="Employee code" autocomplete="off" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg uppercase"> <input type="text" x-model="staffForm.employee_code" placeholder="Employee code" autocomplete="off" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg uppercase">
</template> </template>
<template x-if="staffIdentifyMode === 'badge'"> <template x-if="staffIdentifyMode === 'badge'">
<input type="text" x-model="staffForm.qr_token" placeholder="Scan badge QR code" autocomplete="off" x-ref="staffQrInput" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg"> <div class="space-y-3">
<div x-show="!staffQrScanned" id="staff-qr-reader" class="overflow-hidden rounded-xl border border-slate-200 bg-slate-900/5"></div>
<div x-show="staffQrScanned" x-cloak class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-6 text-center">
<p class="text-sm font-semibold text-emerald-800">Badge scanned</p>
<p class="mt-1 text-sm text-emerald-700">Enter your PIN below to continue.</p>
<button type="button" @click="rescanStaffBadge()" class="mt-3 text-sm font-medium text-indigo-600 hover:text-indigo-800">Scan again</button>
</div>
<p x-show="staffQrError" x-cloak class="rounded-xl bg-amber-50 px-4 py-3 text-sm text-amber-800" x-text="staffQrError"></p>
<details class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<summary class="cursor-pointer font-medium text-slate-700">Using an external scanner?</summary>
<input type="text" x-model="staffForm.qr_token" placeholder="Scan or paste badge code" autocomplete="off" class="mt-3 w-full rounded-lg border-slate-200 px-3 py-3 text-base">
</details>
</div>
</template> </template>
<input type="password" inputmode="numeric" x-model="staffForm.pin" placeholder="PIN (46 digits)" maxlength="6" autocomplete="off" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg tracking-widest"> <input type="password" inputmode="numeric" x-model="staffForm.pin" placeholder="PIN (46 digits)" maxlength="6" autocomplete="off" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg tracking-widest">
</div> </div>