Fix kiosk Continue by registering Alpine component and isolating form steps.
Deploy Ladill Frontdesk / deploy (push) Successful in 41s
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>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import collapse from '@alpinejs/collapse';
|
||||
import { registerKioskFlow } from './kiosk-flow';
|
||||
|
||||
Alpine.plugin(collapse);
|
||||
document.addEventListener('alpine:init', () => registerKioskFlow(Alpine));
|
||||
|
||||
// In-app notification bell + dropdown.
|
||||
Alpine.data('notificationDropdown', (config = {}) => ({
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,14 @@
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>Visitor Check-in · {{ $organization->name }}</title>
|
||||
@include('partials.favicon')
|
||||
<script>
|
||||
window.__KIOSK__ = {
|
||||
visitorTypes: @json($visitorTypeCards),
|
||||
typeConfigs: @json($typeConfigs),
|
||||
resetSeconds: {{ $resetSeconds }},
|
||||
checkInUrl: @json($checkInUrl ?? route('frontdesk.kiosk.check-in')),
|
||||
};
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
<style>
|
||||
@keyframes kiosk-tap-pulse {
|
||||
@@ -20,7 +28,7 @@
|
||||
.kiosk-tap-btn { animation: kiosk-tap-pulse 2.4s ease-in-out infinite; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 font-sans text-slate-900 antialiased" x-data="kioskFlow()" x-init="startTimer()">
|
||||
<body class="min-h-screen bg-slate-100 font-sans text-slate-900 antialiased" x-data="kioskFlow" x-init="startTimer()">
|
||||
<div class="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute -left-24 top-0 h-72 w-72 rounded-full bg-indigo-200/40 blur-3xl"></div>
|
||||
<div class="absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-violet-200/40 blur-3xl"></div>
|
||||
@@ -116,26 +124,31 @@
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between text-xs font-medium text-slate-500">
|
||||
<span x-text="typeConfigs[form.visitor_type]?.label"></span>
|
||||
<span x-text="`Step ${formStep} of ${totalFormSteps}`"></span>
|
||||
<span x-text="`Step ${formStep} of ${totalFormSteps()}`"></span>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full bg-gradient-to-r from-indigo-600 to-violet-600 transition-all duration-300"
|
||||
:style="`width: ${(formStep / totalFormSteps) * 100}%`"></div>
|
||||
:style="`width: ${(formStep / totalFormSteps()) * 100}%`"></div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm font-semibold text-slate-900" x-text="currentFormStepLabel"></p>
|
||||
<p class="mt-2 text-sm font-semibold text-slate-900" x-text="currentFormStepLabel()"></p>
|
||||
</div>
|
||||
|
||||
<form novalidate @submit.prevent="isLastFormStep ? submitCheckIn() : nextFormStep()">
|
||||
<p x-show="errorMessage" x-cloak class="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMessage"></p>
|
||||
|
||||
<div>
|
||||
{{-- Step 1: Identity --}}
|
||||
<div x-show="formStep === 1" class="space-y-4">
|
||||
<template x-if="currentStepKind() === 'identity'">
|
||||
<div class="space-y-4">
|
||||
<input type="text" x-model="form.full_name" placeholder="Full name *" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<input type="text" x-model="form.company" placeholder="Company" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<input type="tel" x-model="form.phone" placeholder="Phone" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<input type="email" x-model="form.email" placeholder="Email" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
</div>
|
||||
<input type="text" x-model="form.phone" placeholder="Phone" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<input type="text" x-model="form.email" placeholder="Email" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Step 2: Visit --}}
|
||||
<div x-show="formStep === 2" class="space-y-4">
|
||||
<template x-if="currentStepKind() === 'visit'">
|
||||
<div class="space-y-4">
|
||||
<select x-model="form.host_id" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<option value="">Who are you visiting?</option>
|
||||
@foreach ($hosts as $host)
|
||||
@@ -143,10 +156,12 @@
|
||||
@endforeach
|
||||
</select>
|
||||
<input type="text" x-model="form.purpose" placeholder="Purpose of visit" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Step 3: Type-specific fields (when present) --}}
|
||||
<div x-show="hasTypeFields && formStep === 3" class="space-y-4">
|
||||
<template x-if="currentStepKind() === 'typeFields'">
|
||||
<div class="space-y-4">
|
||||
<template x-for="field in typeConfigs[form.visitor_type]?.fields || []" :key="field.name">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" x-text="field.label"></label>
|
||||
@@ -170,10 +185,12 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Final step: Photo & finish --}}
|
||||
<div x-show="formStep === finishStep()" class="space-y-4">
|
||||
<template x-if="currentStepKind() === 'finish'">
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<p class="mb-2 text-sm font-medium text-slate-600">Visitor photo (optional)</p>
|
||||
<video x-ref="camera" x-show="cameraActive" autoplay playsinline class="mb-2 w-full max-h-48 rounded-lg bg-slate-900 object-cover"></video>
|
||||
@@ -193,17 +210,18 @@
|
||||
<input type="checkbox" x-model="form.policies_accepted" class="h-5 w-5 rounded">
|
||||
I have read and accept the visitor policies
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mt-6">
|
||||
<button type="button"
|
||||
@click="isLastFormStep ? submitCheckIn() : nextFormStep()"
|
||||
@click="handleContinue()"
|
||||
:disabled="loading"
|
||||
class="btn-primary w-full py-3 disabled:opacity-50">
|
||||
<span x-text="loading ? 'Submitting…' : (isLastFormStep ? 'Complete check-in' : 'Continue')"></span>
|
||||
<span x-text="loading ? 'Submitting…' : (isLastFormStep() ? 'Complete check-in' : 'Continue')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -224,280 +242,5 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function kioskFlow() {
|
||||
return {
|
||||
step: 'welcome',
|
||||
formStep: 1,
|
||||
typeIndex: 0,
|
||||
typeTouchStart: null,
|
||||
loading: false,
|
||||
result: null,
|
||||
timer: null,
|
||||
visitorTypes: @json($visitorTypeCards),
|
||||
typeConfigs: @json($typeConfigs),
|
||||
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: {},
|
||||
},
|
||||
|
||||
get hasTypeFields() {
|
||||
return (this.typeConfigs[this.form.visitor_type]?.fields || []).length > 0;
|
||||
},
|
||||
|
||||
get totalFormSteps() {
|
||||
return this.hasTypeFields ? 4 : 3;
|
||||
},
|
||||
|
||||
get isLastFormStep() {
|
||||
return this.formStep === this.finishStep();
|
||||
},
|
||||
|
||||
get currentFormStepLabel() {
|
||||
if (this.formStep === 1) return 'About you';
|
||||
if (this.formStep === 2) return 'Your visit';
|
||||
if (this.hasTypeFields && this.formStep === 3) return 'Additional details';
|
||||
return 'Photo & finish';
|
||||
},
|
||||
|
||||
finishStep() {
|
||||
return this.hasTypeFields ? 4 : 3;
|
||||
},
|
||||
|
||||
selectType(type) {
|
||||
this.form.visitor_type = type;
|
||||
this.form.type_fields = {};
|
||||
this.formStep = 1;
|
||||
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;
|
||||
},
|
||||
|
||||
validateFormStep() {
|
||||
if (this.formStep === 1) {
|
||||
if (!this.form.full_name.trim()) {
|
||||
alert('Please enter your full name.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.hasTypeFields && this.formStep === 3) {
|
||||
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) {
|
||||
alert(`Please confirm: ${field.label}`);
|
||||
return false;
|
||||
}
|
||||
if (field.type !== 'checkbox' && (!val || String(val).trim() === '')) {
|
||||
alert(`Please complete: ${field.label}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isLastFormStep && !this.form.policies_accepted) {
|
||||
alert('Please accept the visitor policies.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
nextFormStep() {
|
||||
if (!this.validateFormStep()) return;
|
||||
if (this.formStep < this.totalFormSteps) {
|
||||
this.formStep++;
|
||||
this.resetTimer();
|
||||
}
|
||||
},
|
||||
|
||||
prevFormStep() {
|
||||
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(), {{ $resetSeconds }} * 1000);
|
||||
},
|
||||
|
||||
startTimer() { this.resetTimer(); },
|
||||
|
||||
reset() {
|
||||
this.stopCamera();
|
||||
this.step = 'welcome';
|
||||
this.formStep = 1;
|
||||
this.typeIndex = 0;
|
||||
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) {
|
||||
alert('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.validateFormStep()) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const payload = {
|
||||
...this.form,
|
||||
photo_data: this.form.photo_data || undefined,
|
||||
policies_accepted: this.form.policies_accepted ? 1 : 0,
|
||||
};
|
||||
const res = await fetch('{{ $checkInUrl ?? route('frontdesk.kiosk.check-in') }}', {
|
||||
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) {
|
||||
alert('Check-in failed. Please ask reception for help.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user