From 452bd8e009fba8cec1b2b9e30c6f572851f4e4ea Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 27 Jun 2026 22:19:04 +0000 Subject: [PATCH] Fix kiosk Continue by registering Alpine component and isolating form steps. Move kiosk logic into Alpine.data with explicit step methods, render one step at a time with x-if, and show inline validation errors. Co-authored-by: Cursor --- resources/js/app.js | 2 + resources/js/kiosk-flow.js | 309 ++++++++++++++++ .../views/frontdesk/kiosk/index.blade.php | 329 ++---------------- 3 files changed, 347 insertions(+), 293 deletions(-) create mode 100644 resources/js/kiosk-flow.js diff --git a/resources/js/app.js b/resources/js/app.js index ccd0523..1410c37 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,7 +1,9 @@ import Alpine from 'alpinejs'; import collapse from '@alpinejs/collapse'; +import { registerKioskFlow } from './kiosk-flow'; Alpine.plugin(collapse); +document.addEventListener('alpine:init', () => registerKioskFlow(Alpine)); // In-app notification bell + dropdown. Alpine.data('notificationDropdown', (config = {}) => ({ diff --git a/resources/js/kiosk-flow.js b/resources/js/kiosk-flow.js new file mode 100644 index 0000000..1e67ef5 --- /dev/null +++ b/resources/js/kiosk-flow.js @@ -0,0 +1,309 @@ +export function registerKioskFlow(Alpine) { + Alpine.data('kioskFlow', () => { + const config = window.__KIOSK__ || {}; + + return { + step: 'welcome', + formStep: 1, + typeIndex: 0, + typeTouchStart: null, + loading: false, + errorMessage: '', + result: null, + timer: null, + visitorTypes: config.visitorTypes || [], + typeConfigs: config.typeConfigs || {}, + resetSeconds: config.resetSeconds || 120, + checkInUrl: config.checkInUrl || '', + signing: { active: false, field: null }, + canvases: {}, + cameraActive: false, + cameraStream: null, + form: { + full_name: '', + company: '', + phone: '', + email: '', + host_id: '', + visitor_type: 'visitor', + purpose: '', + photo_data: '', + policies_accepted: false, + type_fields: {}, + }, + + hasTypeFields() { + return (this.typeConfigs[this.form.visitor_type]?.fields || []).length > 0; + }, + + totalFormSteps() { + return this.hasTypeFields() ? 4 : 3; + }, + + finishStep() { + return this.totalFormSteps(); + }, + + isLastFormStep() { + return this.formStep >= this.finishStep(); + }, + + currentStepKind() { + if (this.formStep === 1) return 'identity'; + if (this.formStep === 2) return 'visit'; + if (this.hasTypeFields() && this.formStep === 3) return 'typeFields'; + return 'finish'; + }, + + currentFormStepLabel() { + const labels = { + identity: 'About you', + visit: 'Your visit', + typeFields: 'Additional details', + finish: 'Photo & finish', + }; + return labels[this.currentStepKind()] || ''; + }, + + selectType(type) { + this.form.visitor_type = type; + this.form.type_fields = {}; + this.formStep = 1; + this.errorMessage = ''; + this.step = 'details'; + this.resetTimer(); + }, + + nextType() { + if (this.typeIndex < this.visitorTypes.length - 1) { + this.typeIndex++; + this.resetTimer(); + } + }, + + prevType() { + if (this.typeIndex > 0) { + this.typeIndex--; + this.resetTimer(); + } + }, + + onTypeTouchStart(e) { + this.typeTouchStart = e.touches[0].clientX; + }, + + onTypeTouchEnd(e) { + if (this.typeTouchStart === null) return; + const delta = e.changedTouches[0].clientX - this.typeTouchStart; + if (delta > 60) this.prevType(); + else if (delta < -60) this.nextType(); + this.typeTouchStart = null; + }, + + showError(message) { + this.errorMessage = message; + }, + + validateCurrentStep() { + this.errorMessage = ''; + const kind = this.currentStepKind(); + + if (kind === 'identity') { + if (!String(this.form.full_name || '').trim()) { + this.showError('Please enter your full name.'); + return false; + } + } + + if (kind === 'typeFields') { + const fields = this.typeConfigs[this.form.visitor_type]?.fields || []; + for (const field of fields) { + if (!field.required) continue; + const val = this.form.type_fields[field.name]; + if (field.type === 'checkbox' && !val) { + this.showError(`Please confirm: ${field.label}`); + return false; + } + if (field.type !== 'checkbox' && (!val || String(val).trim() === '')) { + this.showError(`Please complete: ${field.label}`); + return false; + } + } + } + + if (kind === 'finish' && !this.form.policies_accepted) { + this.showError('Please accept the visitor policies.'); + return false; + } + + return true; + }, + + handleContinue() { + if (this.isLastFormStep()) { + this.submitCheckIn(); + return; + } + this.nextFormStep(); + }, + + nextFormStep() { + if (!this.validateCurrentStep()) return; + if (this.formStep < this.totalFormSteps()) { + this.formStep++; + this.resetTimer(); + } + }, + + prevFormStep() { + this.errorMessage = ''; + if (this.formStep > 1) { + this.formStep--; + this.resetTimer(); + } else { + this.step = 'type'; + this.resetTimer(); + } + }, + + goBack() { + if (this.step === 'type') { + this.step = 'welcome'; + this.typeIndex = 0; + } else if (this.step === 'details') { + this.prevFormStep(); + } + this.resetTimer(); + }, + + resetTimer() { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.reset(), this.resetSeconds * 1000); + }, + + startTimer() { + this.resetTimer(); + }, + + reset() { + this.stopCamera(); + this.step = 'welcome'; + this.formStep = 1; + this.typeIndex = 0; + this.errorMessage = ''; + this.result = null; + this.form = { + full_name: '', company: '', phone: '', email: '', host_id: '', + visitor_type: 'visitor', purpose: '', photo_data: '', + policies_accepted: false, type_fields: {}, + }; + this.resetTimer(); + }, + + async startCamera() { + try { + this.cameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } }); + this.$refs.camera.srcObject = this.cameraStream; + this.cameraActive = true; + } catch (e) { + this.showError('Camera unavailable on this device.'); + } + }, + + capturePhoto() { + const video = this.$refs.camera; + if (!video?.videoWidth) return; + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext('2d').drawImage(video, 0, 0); + this.form.photo_data = canvas.toDataURL('image/jpeg', 0.85); + this.stopCamera(); + }, + + clearPhoto() { + this.form.photo_data = ''; + }, + + stopCamera() { + this.cameraStream?.getTracks().forEach(track => track.stop()); + this.cameraStream = null; + this.cameraActive = false; + }, + + startSign(e, field) { + this.signing = { active: true, field }; + this.canvases[field] = e.target; + const ctx = e.target.getContext('2d'); + ctx.strokeStyle = '#0f172a'; + ctx.lineWidth = 2; + ctx.beginPath(); + const p = this.pointer(e); + ctx.moveTo(p.x, p.y); + }, + + drawSign(e, field) { + if (!this.signing.active || this.signing.field !== field) return; + const ctx = this.canvases[field].getContext('2d'); + const p = this.pointer(e); + ctx.lineTo(p.x, p.y); + ctx.stroke(); + }, + + endSign(field) { + if (this.signing.field === field) { + this.form.type_fields[field] = this.canvases[field].toDataURL('image/png'); + } + this.signing = { active: false, field: null }; + }, + + clearSign(field) { + const canvas = this.canvases[field]; + if (!canvas) return; + canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); + this.form.type_fields[field] = ''; + }, + + pointer(e) { + const canvas = e.target; + const rect = canvas.getBoundingClientRect(); + const touch = e.touches?.[0]; + const clientX = touch ? touch.clientX : e.clientX; + const clientY = touch ? touch.clientY : e.clientY; + return { x: clientX - rect.left, y: clientY - rect.top }; + }, + + async submitCheckIn() { + if (!this.validateCurrentStep()) return; + + this.loading = true; + this.errorMessage = ''; + try { + const payload = { + ...this.form, + photo_data: this.form.photo_data || undefined, + policies_accepted: this.form.policies_accepted ? 1 : 0, + }; + const res = await fetch(this.checkInUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json', + }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Check-in failed'); + const data = await res.json(); + this.result = data.visit; + this.step = 'done'; + this.resetTimer(); + } catch (e) { + this.showError('Check-in failed. Please ask reception for help.'); + } finally { + this.loading = false; + } + }, + }; + }); +} diff --git a/resources/views/frontdesk/kiosk/index.blade.php b/resources/views/frontdesk/kiosk/index.blade.php index 73b0d00..872bd53 100644 --- a/resources/views/frontdesk/kiosk/index.blade.php +++ b/resources/views/frontdesk/kiosk/index.blade.php @@ -11,6 +11,14 @@ Visitor Check-in · {{ $organization->name }} @include('partials.favicon') + @vite(['resources/css/app.css', 'resources/js/app.js']) - + -
+

+ +
{{-- Step 1: Identity --}} -
+ {{-- Step 2: Visit --}} -
+ {{-- Step 3: Type-specific fields (when present) --}} -
+ -
+
+ {{-- Final step: Photo & finish --}} -
+
- +
@@ -224,280 +242,5 @@ - -