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:
@@ -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 & staff sign-in</p>
|
||||
<p class="mt-4 text-lg text-slate-500">Visitor check-in, check-out & 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">
|
||||
|
||||
Reference in New Issue
Block a user