Align kiosk visitor check-out UI with staff sign-in.
Deploy Ladill Frontdesk / deploy (push) Successful in 48s

Use the same two-step card, mode cards, and badge scan flow before confirming check-out.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 09:30:34 +00:00
co-authored by Cursor
parent 96be429d96
commit 4e449bc1eb
2 changed files with 223 additions and 69 deletions
+80 -22
View File
@@ -31,6 +31,7 @@ export function registerKioskFlow(Alpine) {
checkInUrl: config.checkInUrl || '',
checkOutUrl: config.checkOutUrl || '',
checkoutIdentifyMode: 'code',
checkoutFormStep: 1,
checkoutForm: { badge_code: '', qr_token: '' },
checkoutQrScanner: null,
checkoutQrScanning: false,
@@ -191,6 +192,17 @@ export function registerKioskFlow(Alpine) {
async goBack() {
if (this.step === 'checkout_identify') {
await this.stopCheckoutQrScanner(true);
if (this.checkoutFormStep === 2) {
this.checkoutFormStep = 1;
this.errorMessage = '';
if (this.checkoutIdentifyMode === 'badge' && ! this.checkoutQrScanned) {
await this.$nextTick();
await this.startCheckoutQrScanner();
}
this.resetTimer();
return;
}
}
if (this.step === 'staff_identify') {
if (this.staffFormStep === 2) {
@@ -247,6 +259,7 @@ export function registerKioskFlow(Alpine) {
this.result = null;
this.checkoutResult = null;
this.checkoutIdentifyMode = 'code';
this.checkoutFormStep = 1;
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrScanned = false;
this.checkoutQrError = '';
@@ -286,18 +299,79 @@ export function registerKioskFlow(Alpine) {
this.step = 'checkout_identify';
this.errorMessage = '';
this.checkoutIdentifyMode = 'code';
this.checkoutFormStep = 1;
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrScanned = false;
this.checkoutQrError = '';
this.resetTimer();
},
checkoutIdentifyStepLabel() {
return this.checkoutFormStep === 1 ? 'Identify your visit' : 'Confirm check-out';
},
validateCheckoutIdentifyStep1() {
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 to continue.');
return false;
}
this.errorMessage = '';
return true;
},
async nextCheckoutIdentifyStep() {
if (! this.validateCheckoutIdentifyStep1()) {
return;
}
await this.stopCheckoutQrScanner(false);
this.checkoutFormStep = 2;
this.errorMessage = '';
this.resetTimer();
},
async prevCheckoutIdentifyStep() {
if (this.checkoutFormStep === 2) {
this.checkoutFormStep = 1;
this.errorMessage = '';
if (this.checkoutIdentifyMode === 'badge' && ! this.checkoutQrScanned) {
await this.$nextTick();
await this.startCheckoutQrScanner();
}
this.resetTimer();
return;
}
await this.goBack();
},
async checkoutIdentify() {
if (this.checkoutFormStep === 1) {
await this.nextCheckoutIdentifyStep();
return;
}
await this.submitCheckOut();
},
async setCheckoutIdentifyMode(mode) {
if (this.checkoutIdentifyMode === mode) {
return;
}
await this.stopCheckoutQrScanner(true);
this.checkoutIdentifyMode = mode;
this.checkoutFormStep = 1;
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrError = '';
if (mode === 'badge') {
@@ -323,7 +397,7 @@ export function registerKioskFlow(Alpine) {
},
async startCheckoutQrScanner() {
if (this.checkoutQrScanning || this.checkoutQrScanned || this.checkoutIdentifyMode !== 'badge' || this.step !== 'checkout_identify') {
if (this.checkoutQrScanning || this.checkoutQrScanned || this.checkoutIdentifyMode !== 'badge' || this.step !== 'checkout_identify' || this.checkoutFormStep !== 1) {
return;
}
@@ -349,7 +423,7 @@ export function registerKioskFlow(Alpine) {
);
this.checkoutQrScanning = true;
} catch (e) {
this.checkoutQrError = 'Camera unavailable. Enter your badge code instead.';
this.checkoutQrError = 'Camera unavailable. Use an external scanner or switch to badge code.';
console.error('Checkout QR scanner failed', e);
}
},
@@ -363,12 +437,14 @@ export function registerKioskFlow(Alpine) {
this.checkoutQrScanned = true;
this.errorMessage = '';
await this.stopCheckoutQrScanner(false);
await this.submitCheckOut();
this.checkoutFormStep = 2;
this.resetTimer();
},
async rescanCheckoutBadge() {
this.checkoutQrScanned = false;
this.checkoutForm.qr_token = '';
this.checkoutFormStep = 1;
this.checkoutQrError = '';
await this.$nextTick();
await this.startCheckoutQrScanner();
@@ -395,24 +471,6 @@ export function registerKioskFlow(Alpine) {
}
},
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()) {
@@ -425,7 +483,7 @@ export function registerKioskFlow(Alpine) {
},
async submitCheckOut() {
if (! this.validateCheckoutIdentify()) {
if (! this.validateCheckoutIdentifyStep1()) {
return;
}
+143 -47
View File
@@ -589,66 +589,161 @@
{{-- 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="relative flex min-h-[calc(100vh-5rem)] flex-col justify-center 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 class="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="mb-6">
<div class="flex items-center justify-between text-xs font-medium text-slate-500">
<span>Visitor check-out</span>
<span x-text="`Step ${checkoutFormStep} of 2`"></span>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full bg-gradient-to-r from-violet-600 to-indigo-600 transition-all duration-300"
:style="`width: ${(checkoutFormStep / 2) * 100}%`"></div>
</div>
<p class="mt-2 text-sm font-semibold text-slate-900" x-text="checkoutIdentifyStepLabel()"></p>
</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">
<p x-show="errorMessage" x-cloak class="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMessage"></p>
{{-- Step 1: Identity --}}
<template x-if="checkoutFormStep === 1">
<div>
<div class="grid grid-cols-2 gap-3">
<button type="button"
@click="setCheckoutIdentifyMode('code')"
class="group flex flex-col items-center rounded-2xl border-2 px-4 py-5 text-center transition active:scale-[0.98]"
:class="checkoutIdentifyMode === 'code' ? 'border-violet-300 bg-violet-50 shadow-sm' : 'border-slate-200 bg-white hover:border-violet-200'">
<span class="flex h-11 w-11 items-center justify-center rounded-xl transition"
:class="checkoutIdentifyMode === 'code' ? 'bg-violet-100 text-violet-600' : 'bg-slate-100 text-slate-500 group-hover:bg-violet-50 group-hover:text-violet-600'">
<svg class="h-6 w-6" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g clip-path="url(#checkout-code-key-clip)">
<path d="M7 13.5V8.25C7 7.91848 7.1317 7.60054 7.36612 7.36612C7.60054 7.1317 7.91848 7 8.25 7V7C8.58152 7 8.89946 7.1317 9.13388 7.36612C9.3683 7.60054 9.5 7.91848 9.5 8.25V11H11.5C12.0304 11 12.5391 11.2107 12.9142 11.5858C13.2893 11.9609 13.5 12.4696 13.5 13V13.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 1.5C1.27614 1.5 1.5 1.27614 1.5 1C1.5 0.723858 1.27614 0.5 1 0.5C0.723858 0.5 0.5 0.723858 0.5 1C0.5 1.27614 0.723858 1.5 1 1.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 1.5C5.27614 1.5 5.5 1.27614 5.5 1C5.5 0.723858 5.27614 0.5 5 0.5C4.72386 0.5 4.5 0.723858 4.5 1C4.5 1.27614 4.72386 1.5 5 1.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 1.5C9.27614 1.5 9.5 1.27614 9.5 1C9.5 0.723858 9.27614 0.5 9 0.5C8.72386 0.5 8.5 0.723858 8.5 1C8.5 1.27614 8.72386 1.5 9 1.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 5C1.27614 5 1.5 4.77614 1.5 4.5C1.5 4.22386 1.27614 4 1 4C0.723858 4 0.5 4.22386 0.5 4.5C0.5 4.77614 0.723858 5 1 5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 5C5.27614 5 5.5 4.77614 5.5 4.5C5.5 4.22386 5.27614 4 5 4C4.72386 4 4.5 4.22386 4.5 4.5C4.5 4.77614 4.72386 5 5 5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 5C9.27614 5 9.5 4.77614 9.5 4.5C9.5 4.22386 9.27614 4 9 4C8.72386 4 8.5 4.22386 8.5 4.5C8.5 4.77614 8.72386 5 9 5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 8.5C1.27614 8.5 1.5 8.27614 1.5 8C1.5 7.72386 1.27614 7.5 1 7.5C0.723858 7.5 0.5 7.72386 0.5 8C0.5 8.27614 0.723858 8.5 1 8.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8.5C4.86739 8.5 4.74021 8.44732 4.64645 8.35355C4.55268 8.25979 4.5 8.13261 4.5 8C4.5 7.86739 4.55268 7.74021 4.64645 7.64645C4.74021 7.55268 4.86739 7.5 5 7.5V8.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="checkout-code-key-clip">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>
</span>
<span class="mt-3 text-sm font-semibold text-slate-900">Badge code</span>
<span class="mt-0.5 text-xs text-slate-500">Type your code</span>
</button>
<button type="button"
@click="setCheckoutIdentifyMode('badge')"
class="group flex flex-col items-center rounded-2xl border-2 px-4 py-5 text-center transition active:scale-[0.98]"
:class="checkoutIdentifyMode === 'badge' ? 'border-violet-300 bg-violet-50 shadow-sm' : 'border-slate-200 bg-white hover:border-violet-200'">
<span class="flex h-11 w-11 items-center justify-center rounded-xl transition"
:class="checkoutIdentifyMode === 'badge' ? 'bg-violet-100 text-violet-600' : 'bg-slate-100 text-slate-500 group-hover:bg-violet-50 group-hover:text-violet-600'">
<svg class="h-6 w-6" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g clip-path="url(#checkout-scan-badge-clip)">
<path d="M10.5 0.5H12.5C12.7652 0.5 13.0196 0.605357 13.2071 0.792893C13.3946 0.98043 13.5 1.23478 13.5 1.5V3.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.5 3.5V1.5C0.5 1.23478 0.605357 0.98043 0.792893 0.792893C0.98043 0.605357 1.23478 0.5 1.5 0.5H3.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.5 13.5H12.5C12.7652 13.5 13.0196 13.3946 13.2071 13.2071C13.3946 13.0196 13.5 12.7652 13.5 12.5V10.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.5 10.5V12.5C0.5 12.7652 0.605357 13.0196 0.792893 13.2071C0.98043 13.3946 1.23478 13.5 1.5 13.5H3.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 10.5C8.933 10.5 10.5 8.933 10.5 7C10.5 5.067 8.933 3.5 7 3.5C5.067 3.5 3.5 5.067 3.5 7C3.5 8.933 5.067 10.5 7 10.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="checkout-scan-badge-clip">
<rect width="14" height="14" fill="white"/>
</clipPath>
</defs>
</svg>
</span>
<span class="mt-3 text-sm font-semibold text-slate-900">Scan badge</span>
<span class="mt-0.5 text-xs text-slate-500">Use camera or scanner</span>
</button>
</div>
<div class="mt-6">
<template x-if="checkoutIdentifyMode === 'code'">
<div>
<label for="checkout-badge-code" class="mb-1.5 block text-sm font-medium text-slate-600">Badge code</label>
<input id="checkout-badge-code"
type="text"
x-model="checkoutForm.badge_code"
placeholder="e.g. ABC12345"
autocomplete="off"
class="w-full rounded-xl border-slate-200 px-4 py-4 font-mono text-lg uppercase tracking-widest">
</div>
</template>
<template x-if="checkoutIdentifyMode === 'badge'">
<div class="space-y-3">
<div x-show="!checkoutQrScanned">
<label class="mb-1.5 block text-sm font-medium text-slate-600">Badge QR code</label>
<div id="checkout-qr-reader" class="overflow-hidden rounded-xl border border-slate-200 bg-slate-900/5"></div>
<p class="mt-2 text-center text-xs text-slate-500">Hold your badge in front of the camera</p>
</div>
<div x-show="checkoutQrScanned" x-cloak class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-5 text-center">
<span class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</span>
<p class="mt-3 text-sm font-semibold text-emerald-800">Badge recognized</p>
<p class="mt-1 text-sm text-emerald-700">Continue to confirm check-out.</p>
<button type="button" @click="rescanCheckoutBadge()" class="mt-3 text-sm font-medium text-violet-600 hover:text-violet-800">Scan a different badge</button>
</div>
<p x-show="checkoutQrError" x-cloak class="rounded-xl bg-amber-50 px-4 py-3 text-sm text-amber-800" x-text="checkoutQrError"></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>
<label class="mb-1.5 mt-3 block text-xs font-medium text-slate-500">Badge QR data</label>
<input type="text"
x-model="checkoutForm.qr_token"
placeholder="Scan or paste badge code"
autocomplete="off"
class="w-full rounded-lg border-slate-200 px-3 py-3 font-mono text-base">
</details>
</div>
</template>
</div>
</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>
{{-- Step 2: Confirm --}}
<template x-if="checkoutFormStep === 2">
<div class="space-y-4">
<div x-show="checkoutIdentifyMode === 'badge' && checkoutQrScanned" x-cloak class="flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</span>
<div class="min-w-0 text-left">
<p class="text-sm font-semibold text-emerald-800">Badge verified</p>
<p class="text-xs text-emerald-700">Confirm to check out and leave.</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>
<div x-show="checkoutIdentifyMode === 'code'" x-cloak class="flex items-center gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-violet-100 font-mono text-sm font-bold text-violet-700" x-text="(checkoutForm.badge_code || '').slice(0, 2).toUpperCase() || '••'"></span>
<div class="min-w-0 text-left">
<p class="text-sm font-semibold text-slate-900">Visitor badge</p>
<p class="truncate font-mono text-xs text-slate-600" x-text="checkoutForm.badge_code"></p>
</div>
</template>
</div>
<p class="text-center text-sm text-slate-500">Tap below to complete your visit and check out.</p>
</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 class="mt-6">
<button type="button"
@click="checkoutIdentify()"
:disabled="loading"
class="btn-primary w-full py-3 disabled:opacity-50">
<span x-text="loading ? 'Checking out…' : (checkoutFormStep === 1 ? 'Continue' : 'Check out')"></span>
</button>
</div>
</div>
</div>
</template>
@@ -660,6 +755,7 @@
<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-1 text-sm text-slate-500" x-show="checkoutResult?.badge_code">Badge <span class="font-mono font-semibold" x-text="checkoutResult?.badge_code"></span></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>