Add visitor self-checkout on the kiosk.
Deploy Ladill Frontdesk / deploy (push) Successful in 46s

Let guests check out by badge code or QR scan before leaving, with device and staff kiosk API routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 09:07:39 +00:00
co-authored by Cursor
parent fedc1b2763
commit 0c092b7711
8 changed files with 505 additions and 3 deletions
+201
View File
@@ -29,6 +29,14 @@ export function registerKioskFlow(Alpine) {
typeConfigs: config.typeConfigs || {},
resetSeconds: config.resetSeconds || 120,
checkInUrl: config.checkInUrl || '',
checkOutUrl: config.checkOutUrl || '',
checkoutIdentifyMode: 'code',
checkoutForm: { badge_code: '', qr_token: '' },
checkoutQrScanner: null,
checkoutQrScanning: false,
checkoutQrScanned: false,
checkoutQrError: '',
checkoutResult: null,
signing: { active: false, field: null },
canvases: {},
cameraActive: false,
@@ -181,6 +189,9 @@ export function registerKioskFlow(Alpine) {
},
async goBack() {
if (this.step === 'checkout_identify') {
await this.stopCheckoutQrScanner(true);
}
if (this.step === 'staff_identify') {
if (this.staffFormStep === 2) {
this.staffFormStep = 1;
@@ -209,6 +220,8 @@ export function registerKioskFlow(Alpine) {
this.step = 'staff_identify';
} else if (this.step === 'staff_step_out') {
this.step = 'staff_actions';
} else if (this.step === 'checkout_identify') {
this.step = this.employeeKioskEnabled ? 'choose' : 'welcome';
}
this.errorMessage = '';
this.resetTimer();
@@ -225,12 +238,18 @@ export function registerKioskFlow(Alpine) {
reset() {
this.stopStaffQrScanner(true);
this.stopCheckoutQrScanner(true);
this.stopCamera();
this.step = 'welcome';
this.formStep = 1;
this.typeIndex = 0;
this.errorMessage = '';
this.result = null;
this.checkoutResult = null;
this.checkoutIdentifyMode = 'code';
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrScanned = false;
this.checkoutQrError = '';
this.staffEmployee = null;
this.staffMessage = '';
this.staffBranchWarning = '';
@@ -262,6 +281,188 @@ export function registerKioskFlow(Alpine) {
this.resetTimer();
},
async startCheckoutFlow() {
await this.stopCheckoutQrScanner(true);
this.step = 'checkout_identify';
this.errorMessage = '';
this.checkoutIdentifyMode = 'code';
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrScanned = false;
this.checkoutQrError = '';
this.resetTimer();
},
async setCheckoutIdentifyMode(mode) {
if (this.checkoutIdentifyMode === mode) {
return;
}
await this.stopCheckoutQrScanner(true);
this.checkoutIdentifyMode = mode;
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrError = '';
if (mode === 'badge') {
await this.$nextTick();
await this.startCheckoutQrScanner();
}
},
parseVisitQr(text) {
const trimmed = String(text || '').trim();
if (trimmed === '') {
return null;
}
const urlMatch = trimmed.match(/\/q\/([A-Za-z0-9]+)/);
if (urlMatch) {
return urlMatch[1];
}
if (/^[A-Za-z0-9]{16,64}$/.test(trimmed)) {
return trimmed;
}
return null;
},
async startCheckoutQrScanner() {
if (this.checkoutQrScanning || this.checkoutQrScanned || this.checkoutIdentifyMode !== 'badge' || this.step !== 'checkout_identify') {
return;
}
this.checkoutQrError = '';
try {
const { Html5Qrcode } = await import('html5-qrcode');
await this.$nextTick();
if (! document.getElementById('checkout-qr-reader')) {
return;
}
if (this.checkoutQrScanner) {
await this.stopCheckoutQrScanner(false);
}
this.checkoutQrScanner = new Html5Qrcode('checkout-qr-reader');
await this.checkoutQrScanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 260, height: 260 } },
(decodedText) => this.handleCheckoutBadgeScan(decodedText),
() => {},
);
this.checkoutQrScanning = true;
} catch (e) {
this.checkoutQrError = 'Camera unavailable. Enter your badge code instead.';
console.error('Checkout QR scanner failed', e);
}
},
async handleCheckoutBadgeScan(decodedText) {
if (! this.parseVisitQr(decodedText)) {
return;
}
this.checkoutForm.qr_token = decodedText.trim();
this.checkoutQrScanned = true;
this.errorMessage = '';
await this.stopCheckoutQrScanner(false);
await this.submitCheckOut();
},
async rescanCheckoutBadge() {
this.checkoutQrScanned = false;
this.checkoutForm.qr_token = '';
this.checkoutQrError = '';
await this.$nextTick();
await this.startCheckoutQrScanner();
},
async stopCheckoutQrScanner(clearScanned = false) {
if (this.checkoutQrScanner) {
try {
if (this.checkoutQrScanning) {
await this.checkoutQrScanner.stop();
}
await this.checkoutQrScanner.clear();
} catch (e) {
// Scanner may already be stopped when leaving the step.
}
this.checkoutQrScanner = null;
this.checkoutQrScanning = false;
}
if (clearScanned) {
this.checkoutQrScanned = false;
this.checkoutForm.qr_token = '';
this.checkoutQrError = '';
}
},
validateCheckoutIdentify() {
if (this.checkoutIdentifyMode === 'code') {
if (! String(this.checkoutForm.badge_code || '').trim()) {
this.showError('Please enter your badge code.');
return false;
}
} else if (! String(this.checkoutForm.qr_token || '').trim()) {
this.showError('Scan your badge QR code to continue.');
return false;
}
this.errorMessage = '';
return true;
},
checkoutCredentials() {
const body = {};
if (this.checkoutIdentifyMode === 'badge' && this.checkoutForm.qr_token.trim()) {
body.qr_token = this.checkoutForm.qr_token.trim();
} else {
body.badge_code = this.checkoutForm.badge_code.trim();
}
return body;
},
async submitCheckOut() {
if (! this.validateCheckoutIdentify()) {
return;
}
if (! this.checkOutUrl) {
this.showError('Check-out is not available on this kiosk.');
return;
}
this.loading = true;
this.errorMessage = '';
try {
await this.stopCheckoutQrScanner(false);
const res = await fetch(this.checkOutUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify(this.checkoutCredentials()),
});
const data = await res.json();
if (! res.ok) {
const msg = data.message || data.errors?.credentials?.[0] || 'Check-out failed.';
throw new Error(msg);
}
this.checkoutResult = data.visit;
this.step = 'checkout_done';
this.resetTimer();
} catch (e) {
this.showError(e.message || 'Check-out failed. Please ask reception for help.');
} finally {
this.loading = false;
}
},
startStaffFlow() {
this.stopStaffQrScanner(true);
this.step = 'staff_identify';
@@ -19,6 +19,7 @@
typeConfigs: @json($typeConfigs),
resetSeconds: {{ $resetSeconds }},
checkInUrl: @json($checkInUrl ?? route('frontdesk.kiosk.check-in')),
checkOutUrl: @json($checkOutUrl ?? route('frontdesk.kiosk.check-out')),
employeeKioskEnabled: @json($employeeKioskEnabled ?? false),
staffActionUrl: @json($staffActionUrl ?? ''),
stepOutReasons: @json($stepOutReasons ?? []),
@@ -33,6 +34,8 @@
.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; }
#checkout-qr-reader { min-height: 280px; }
#checkout-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()">
@@ -53,13 +56,19 @@
<h1 class="mt-3 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
Welcome to {{ $organization->name }}
</h1>
<p class="mt-4 text-lg text-slate-500">Visitor check-in &amp; staff sign-in</p>
<p class="mt-4 text-lg text-slate-500">Visitor check-in, check-out &amp; staff sign-in</p>
</div>
<button type="button"
@click="beginKiosk()"
class="kiosk-tap-btn btn-primary btn-primary-lg mt-16 w-full max-w-lg py-8 text-xl font-bold">
Tap to begin
Tap to check in
</button>
<button type="button"
@click="startCheckoutFlow()"
class="mt-4 w-full max-w-lg rounded-2xl border-2 border-slate-200 bg-white py-5 text-lg font-semibold text-slate-700 shadow-sm transition hover:border-indigo-300 hover:bg-slate-50 active:scale-[0.98]">
Check out
</button>
</div>
</template>
@@ -115,6 +124,12 @@
</button>
</template>
</div>
<button type="button"
@click="startCheckoutFlow()"
class="mt-8 text-sm font-medium text-slate-500 underline decoration-slate-300 underline-offset-4 hover:text-indigo-600">
Leaving? Check out here
</button>
</div>
</template>
@@ -578,6 +593,85 @@
</div>
</template>
{{-- Visitor checkout --}}
<template x-if="step === 'checkout_identify'">
<div class="relative flex min-h-[calc(100vh-5rem)] flex-col px-6 py-8">
<div class="absolute left-6 top-8 z-10">
@include('frontdesk.partials.kiosk-back')
</div>
<div class="mx-auto w-full max-w-lg pt-16">
<h2 class="text-2xl font-bold text-slate-900">Check out</h2>
<p class="mt-2 text-slate-500">Enter your badge code or scan the QR code on your visitor badge.</p>
<div class="mt-8 grid grid-cols-2 gap-2 rounded-xl bg-slate-100 p-1">
<button type="button"
@click="setCheckoutIdentifyMode('code')"
:class="checkoutIdentifyMode === 'code' ? 'bg-white shadow text-slate-900' : 'text-slate-500'"
class="rounded-lg px-4 py-3 text-sm font-semibold transition">
Badge code
</button>
<button type="button"
@click="setCheckoutIdentifyMode('badge')"
:class="checkoutIdentifyMode === 'badge' ? 'bg-white shadow text-slate-900' : 'text-slate-500'"
class="rounded-lg px-4 py-3 text-sm font-semibold transition">
Scan badge
</button>
</div>
<template x-if="checkoutIdentifyMode === 'code'">
<div class="mt-6">
<label class="block text-sm font-medium text-slate-700">Badge code</label>
<input type="text"
x-model="checkoutForm.badge_code"
placeholder="e.g. ABC12345"
autocomplete="off"
class="mt-2 w-full rounded-xl border-slate-200 px-4 py-4 text-lg font-mono uppercase tracking-widest">
</div>
</template>
<template x-if="checkoutIdentifyMode === 'badge'">
<div class="mt-6">
<template x-if="!checkoutQrScanned">
<div>
<div id="checkout-qr-reader" class="overflow-hidden rounded-xl border border-slate-200 bg-slate-50"></div>
<p x-show="checkoutQrError" x-text="checkoutQrError" class="mt-3 text-sm text-amber-700"></p>
</div>
</template>
<template x-if="checkoutQrScanned">
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
Badge scanned. Checking you out…
</div>
</template>
</div>
</template>
<p x-show="errorMessage" x-text="errorMessage" class="mt-4 text-sm text-red-600"></p>
<button type="button"
x-show="checkoutIdentifyMode === 'code'"
@click="submitCheckOut()"
:disabled="loading"
class="btn-primary btn-primary-lg mt-8 w-full disabled:opacity-50">
<span x-text="loading ? 'Checking out…' : 'Check out'"></span>
</button>
</div>
</div>
</template>
{{-- Visitor checkout done --}}
<template x-if="step === 'checkout_done'">
<div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-8">
<div class="w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-sm">
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 text-4xl"></div>
<h2 class="text-2xl font-bold">You're checked out</h2>
<p class="mt-2 text-slate-600" x-text="checkoutResult?.visitor_name"></p>
<p class="mt-4 text-sm text-slate-500">Thank you for visiting. Have a safe trip.</p>
<button type="button" @click="reset()" class="btn-primary btn-primary-lg mt-8 w-full">Done</button>
</div>
</div>
</template>
{{-- Done --}}
<template x-if="step === 'done'">
<div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-8">