Files
ladill-frontdesk/resources/js/kiosk-flow.js
T
isaaccladandCursor 7a9871f43f
Deploy Ladill Frontdesk / deploy (push) Successful in 36s
Add tablet camera scanning for employee badge QR on kiosk.
Uses html5-qrcode with rear camera preview on Scan badge, keeps external scanner fallback, and still requires PIN after a successful scan.

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

587 lines
22 KiB
JavaScript

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;
}
},
};
});
}