Files
ladill-frontdesk/resources/js/kiosk-flow.js
T
isaaccladandCursor 49ec452c96
Deploy Ladill Frontdesk / deploy (push) Successful in 30s
Redesign employee kiosk sign-in to match visitor check-in polish.
Split staff identify into two steps with progress header, labeled fields, and icon mode cards; remove uppercase styling on employee code input.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 21:44:06 +00:00

664 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '',
staffFormStep: 1,
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') {
if (this.staffFormStep === 2) {
this.staffFormStep = 1;
this.staffForm.pin = '';
this.errorMessage = '';
if (this.staffIdentifyMode === 'badge' && ! this.staffQrScanned) {
await this.$nextTick();
await this.startStaffQrScanner();
}
this.resetTimer();
return;
}
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.staffFormStep = 1;
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.staffFormStep = 1;
this.staffForm = { employee_code: '', qr_token: '', pin: '' };
this.staffQrScanned = false;
this.staffQrError = '';
this.resetTimer();
},
staffIdentifyStepLabel() {
return this.staffFormStep === 1 ? 'Identify yourself' : 'Enter your PIN';
},
validateStaffIdentifyStep1() {
if (this.staffIdentifyMode === 'code') {
if (! String(this.staffForm.employee_code || '').trim()) {
this.showError('Please enter your employee code.');
return false;
}
} else if (! String(this.staffForm.qr_token || '').trim()) {
this.showError('Scan your badge to continue.');
return false;
}
this.errorMessage = '';
return true;
},
async nextStaffIdentifyStep() {
if (! this.validateStaffIdentifyStep1()) {
return;
}
await this.stopStaffQrScanner(false);
this.staffFormStep = 2;
this.errorMessage = '';
this.resetTimer();
},
async prevStaffIdentifyStep() {
if (this.staffFormStep === 2) {
this.staffFormStep = 1;
this.staffForm.pin = '';
this.errorMessage = '';
if (this.staffIdentifyMode === 'badge' && ! this.staffQrScanned) {
await this.$nextTick();
await this.startStaffQrScanner();
}
this.resetTimer();
return;
}
await this.goBack();
},
async setStaffIdentifyMode(mode) {
if (this.staffIdentifyMode === mode) {
return;
}
await this.stopStaffQrScanner(true);
this.staffIdentifyMode = mode;
this.staffFormStep = 1;
this.staffForm.pin = '';
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' || this.staffFormStep !== 1) {
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.staffFormStep = 2;
this.resetTimer();
},
async rescanStaffBadge() {
this.staffQrScanned = false;
this.staffForm.qr_token = '';
this.staffForm.pin = '';
this.staffFormStep = 1;
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() {
if (this.staffFormStep === 1) {
await this.nextStaffIdentifyStep();
return;
}
if (! /^\d{4,6}$/.test(this.staffForm.pin)) {
this.showError('Enter a 46 digit 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;
}
},
};
});
}