Files
isaaccladandCursor 4e449bc1eb
Deploy Ladill Frontdesk / deploy (push) Successful in 48s
Align kiosk visitor check-out UI with staff sign-in.
Use the same two-step card, mode cards, and badge scan flow before confirming check-out.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 09:30:34 +00:00

923 lines
34 KiB
JavaScript
Raw Permalink 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 || '',
checkOutUrl: config.checkOutUrl || '',
checkoutIdentifyMode: 'code',
checkoutFormStep: 1,
checkoutForm: { badge_code: '', qr_token: '' },
checkoutQrScanner: null,
checkoutQrScanning: false,
checkoutQrScanned: false,
checkoutQrError: '',
checkoutResult: null,
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 === '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) {
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';
} else if (this.step === 'checkout_identify') {
this.step = this.employeeKioskEnabled ? 'choose' : 'welcome';
}
this.errorMessage = '';
this.resetTimer();
},
resetTimer() {
clearTimeout(this.timer);
this.timer = setTimeout(() => this.reset(), this.resetSeconds * 1000);
},
startTimer() {
this.resetTimer();
},
reset() {
this.stopStaffQrScanner(true);
this.stopCheckoutQrScanner(true);
this.stopCamera();
this.step = 'welcome';
this.formStep = 1;
this.typeIndex = 0;
this.errorMessage = '';
this.result = null;
this.checkoutResult = null;
this.checkoutIdentifyMode = 'code';
this.checkoutFormStep = 1;
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrScanned = false;
this.checkoutQrError = '';
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();
},
async startCheckoutFlow() {
await this.stopCheckoutQrScanner(true);
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') {
await this.$nextTick();
await this.startCheckoutQrScanner();
}
},
parseVisitQr(text) {
const trimmed = String(text || '').trim();
if (trimmed === '') {
return null;
}
const urlMatch = trimmed.match(/\/q\/([A-Za-z0-9]+)/);
if (urlMatch) {
return urlMatch[1];
}
if (/^[A-Za-z0-9]{16,64}$/.test(trimmed)) {
return trimmed;
}
return null;
},
async startCheckoutQrScanner() {
if (this.checkoutQrScanning || this.checkoutQrScanned || this.checkoutIdentifyMode !== 'badge' || this.step !== 'checkout_identify' || this.checkoutFormStep !== 1) {
return;
}
this.checkoutQrError = '';
try {
const { Html5Qrcode } = await import('html5-qrcode');
await this.$nextTick();
if (! document.getElementById('checkout-qr-reader')) {
return;
}
if (this.checkoutQrScanner) {
await this.stopCheckoutQrScanner(false);
}
this.checkoutQrScanner = new Html5Qrcode('checkout-qr-reader');
await this.checkoutQrScanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 260, height: 260 } },
(decodedText) => this.handleCheckoutBadgeScan(decodedText),
() => {},
);
this.checkoutQrScanning = true;
} catch (e) {
this.checkoutQrError = 'Camera unavailable. Use an external scanner or switch to badge code.';
console.error('Checkout QR scanner failed', e);
}
},
async handleCheckoutBadgeScan(decodedText) {
if (! this.parseVisitQr(decodedText)) {
return;
}
this.checkoutForm.qr_token = decodedText.trim();
this.checkoutQrScanned = true;
this.errorMessage = '';
await this.stopCheckoutQrScanner(false);
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();
},
async stopCheckoutQrScanner(clearScanned = false) {
if (this.checkoutQrScanner) {
try {
if (this.checkoutQrScanning) {
await this.checkoutQrScanner.stop();
}
await this.checkoutQrScanner.clear();
} catch (e) {
// Scanner may already be stopped when leaving the step.
}
this.checkoutQrScanner = null;
this.checkoutQrScanning = false;
}
if (clearScanned) {
this.checkoutQrScanned = false;
this.checkoutForm.qr_token = '';
this.checkoutQrError = '';
}
},
checkoutCredentials() {
const body = {};
if (this.checkoutIdentifyMode === 'badge' && this.checkoutForm.qr_token.trim()) {
body.qr_token = this.checkoutForm.qr_token.trim();
} else {
body.badge_code = this.checkoutForm.badge_code.trim();
}
return body;
},
async submitCheckOut() {
if (! this.validateCheckoutIdentifyStep1()) {
return;
}
if (! this.checkOutUrl) {
this.showError('Check-out is not available on this kiosk.');
return;
}
this.loading = true;
this.errorMessage = '';
try {
await this.stopCheckoutQrScanner(false);
const res = await fetch(this.checkOutUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify(this.checkoutCredentials()),
});
const data = await res.json();
if (! res.ok) {
const msg = data.message || data.errors?.credentials?.[0] || 'Check-out failed.';
throw new Error(msg);
}
this.checkoutResult = data.visit;
this.step = 'checkout_done';
this.resetTimer();
} catch (e) {
this.showError(e.message || 'Check-out failed. Please ask reception for help.');
} finally {
this.loading = false;
}
},
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;
}
},
};
});
}