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,
|
"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",
|
||||||
|
|||||||
@@ -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
@@ -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 (4–6 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 (4–6 digits)" maxlength="6" autocomplete="off" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg tracking-widest">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user