export function registerKioskFlow(Alpine) { Alpine.data('kioskFlow', () => { const config = window.__KIOSK__ || {}; return { step: 'welcome', employeeKioskEnabled: config.employeeKioskEnabled ?? false, staffActionUrl: config.staffActionUrl || '', stepOutReasons: config.stepOutReasons || [], staffIdentifyMode: 'code', staffForm: { employee_code: '', qr_token: '', pin: '' }, staffQrScanner: null, staffQrScanning: false, staffQrScanned: false, staffQrError: '', staffEmployee: null, staffMessage: '', staffBranchWarning: '', staffStepOut: { step_out_reason: 'lunch', destination: '', notes: '', expected_return_at: '' }, 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(); } }, async goBack() { if (this.step === 'staff_identify') { await this.stopStaffQrScanner(true); } if (this.step === 'type') { this.step = this.employeeKioskEnabled ? 'choose' : 'welcome'; this.typeIndex = 0; } else if (this.step === 'details') { this.prevFormStep(); } else if (this.step === 'choose') { this.step = 'welcome'; } else if (this.step === 'staff_identify') { this.step = 'choose'; } else if (this.step === 'staff_actions') { this.step = 'staff_identify'; } else if (this.step === 'staff_step_out') { this.step = 'staff_actions'; } this.errorMessage = ''; this.resetTimer(); }, resetTimer() { clearTimeout(this.timer); this.timer = setTimeout(() => this.reset(), this.resetSeconds * 1000); }, startTimer() { this.resetTimer(); }, reset() { this.stopStaffQrScanner(true); this.stopCamera(); this.step = 'welcome'; this.formStep = 1; this.typeIndex = 0; this.errorMessage = ''; this.result = null; this.staffEmployee = null; this.staffMessage = ''; this.staffBranchWarning = ''; this.staffIdentifyMode = 'code'; this.staffForm = { employee_code: '', qr_token: '', pin: '' }; this.staffQrScanned = false; this.staffQrError = ''; this.staffStepOut = { step_out_reason: 'lunch', destination: '', notes: '', expected_return_at: '' }; this.form = { full_name: '', company: '', phone: '', email: '', host_id: '', visitor_type: 'visitor', purpose: '', photo_data: '', policies_accepted: false, type_fields: {}, }; this.resetTimer(); }, beginKiosk() { if (this.employeeKioskEnabled) { this.step = 'choose'; } else { this.startVisitorFlow(); } this.resetTimer(); }, startVisitorFlow() { this.step = 'type'; this.resetTimer(); }, startStaffFlow() { this.stopStaffQrScanner(true); this.step = 'staff_identify'; this.errorMessage = ''; this.staffIdentifyMode = 'code'; this.staffForm = { employee_code: '', qr_token: '', pin: '' }; this.staffQrScanned = false; this.staffQrError = ''; 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() { const body = { pin: this.staffForm.pin }; if (this.staffIdentifyMode === 'badge' && this.staffForm.qr_token.trim()) { body.qr_token = this.staffForm.qr_token.trim(); } else { body.employee_code = this.staffForm.employee_code.trim(); } return body; }, async staffPost(action, extra = {}) { this.loading = true; this.errorMessage = ''; try { const res = await fetch(this.staffActionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, 'Accept': 'application/json', }, body: JSON.stringify({ ...this.staffCredentials(), action, ...extra, }), }); const data = await res.json(); if (!res.ok) { const branchMsg = data.errors?.branch_mismatch?.[0]; if (branchMsg && action === 'sign_in') { this.staffBranchWarning = branchMsg; this.step = 'staff_branch_confirm'; return null; } const msg = data.message || data.errors?.credentials?.[0] || data.errors?.action?.[0] || data.errors?.destination?.[0] || 'Request failed.'; throw new Error(msg); } return data; } finally { this.loading = false; } }, async staffIdentify() { const hasCode = this.staffIdentifyMode === 'code' && this.staffForm.employee_code.trim(); const hasBadge = this.staffIdentifyMode === 'badge' && this.staffForm.qr_token.trim(); if ((!hasCode && !hasBadge) || !/^\d{4,6}$/.test(this.staffForm.pin)) { this.showError('Enter your employee code or scan your badge, plus your PIN.'); return; } try { await this.stopStaffQrScanner(false); const data = await this.staffPost('identify'); if (!data) return; this.staffEmployee = data.employee; this.step = 'staff_actions'; this.resetTimer(); } catch (e) { this.showError(e.message || 'Invalid employee code or PIN.'); } }, async staffConfirmSignIn() { try { const data = await this.staffPost('sign_in', { confirm_branch_mismatch: true }); if (!data) return; this.staffEmployee = data.employee; this.staffMessage = data.message || 'Signed in successfully.'; this.step = 'staff_done'; this.staffForm.pin = ''; this.resetTimer(); } catch (e) { this.showError(e.message || 'Sign in failed.'); } }, async staffPerform(action, extra = {}) { try { const data = await this.staffPost(action, extra); if (!data) return; this.staffEmployee = data.employee; this.staffMessage = data.message || 'Done.'; this.step = 'staff_done'; this.staffForm.pin = ''; this.resetTimer(); } catch (e) { this.showError(e.message || 'Action failed.'); } }, openStaffStepOut() { this.staffStepOut = { step_out_reason: 'lunch', destination: '', notes: '', expected_return_at: '' }; this.step = 'staff_step_out'; this.errorMessage = ''; this.resetTimer(); }, submitStaffStepOut() { if (this.staffStepOut.step_out_reason === 'other' && !this.staffStepOut.destination.trim()) { this.showError('Please say where you are going.'); return; } this.staffPerform('step_out', { ...this.staffStepOut }); }, staffActionLabel(action) { const labels = { sign_in: 'Sign in', sign_out: 'Sign out for the day', step_out: 'Step out', step_in: "I'm back", }; return labels[action] || action; }, 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; } }, }; }); }