Deploy Ladill Frontdesk / deploy (push) Successful in 41s
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 <cursoragent@cursor.com>
310 lines
11 KiB
JavaScript
310 lines
11 KiB
JavaScript
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;
|
|
}
|
|
},
|
|
};
|
|
});
|
|
}
|