Add tablet camera scanning for employee badge QR on kiosk.
Deploy Ladill Frontdesk / deploy (push) Successful in 36s
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:
Generated
+7
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+117
-1
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<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 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>
|
||||
<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>
|
||||
<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="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('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('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 class="mt-6 space-y-4">
|
||||
<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">
|
||||
</template>
|
||||
<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>
|
||||
<input type="password" inputmode="numeric" x-model="staffForm.pin" placeholder="PIN (4–6 digits)" maxlength="6" autocomplete="off" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg tracking-widest">
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user