Deploy Ladill Frontdesk / deploy (push) Successful in 48s
Use the same two-step card, mode cards, and badge scan flow before confirming check-out. Co-authored-by: Cursor <cursoragent@cursor.com>
923 lines
34 KiB
JavaScript
923 lines
34 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: '',
|
||
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 4–6 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;
|
||
}
|
||
},
|
||
};
|
||
});
|
||
}
|