Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
256 lines
14 KiB
PHP
256 lines
14 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<title>Visitor Check-in · {{ $organization->name }}</title>
|
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
</head>
|
|
<body class="min-h-screen bg-gradient-to-br from-teal-600 to-cyan-700 text-white" x-data="kioskFlow()" x-init="startTimer()">
|
|
<div class="mx-auto flex min-h-screen max-w-3xl flex-col p-6">
|
|
<header class="mb-8 text-center">
|
|
<h1 class="text-3xl font-bold">{{ $organization->name }}</h1>
|
|
<p class="mt-2 text-teal-100">Welcome — please check in</p>
|
|
</header>
|
|
|
|
<main class="flex-1">
|
|
<template x-if="step === 'welcome'">
|
|
<div class="text-center">
|
|
<button @click="step = 'type'; resetTimer()" class="mx-auto block w-full max-w-md rounded-3xl bg-white px-8 py-16 text-2xl font-bold text-teal-700 shadow-xl transition hover:scale-[1.02]">
|
|
Tap to begin
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="step === 'type'">
|
|
<div class="grid gap-4 sm:grid-cols-2">
|
|
@foreach ($visitorTypes as $key => $label)
|
|
<button @click="selectType('{{ $key }}')"
|
|
class="rounded-2xl bg-white/95 px-6 py-8 text-lg font-semibold text-slate-800 shadow-lg hover:bg-white">
|
|
{{ $label }}
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="step === 'details'">
|
|
<form @submit.prevent="submitCheckIn" class="space-y-4 rounded-3xl bg-white p-6 text-slate-800 shadow-xl">
|
|
<p class="text-sm font-medium text-teal-700" x-text="typeConfigs[form.visitor_type]?.label"></p>
|
|
|
|
<input type="text" x-model="form.full_name" required 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">
|
|
<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)
|
|
<option value="{{ $host->id }}">{{ $host->name }}</option>
|
|
@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 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>
|
|
<img x-show="form.photo_data" :src="form.photo_data" alt="Captured photo preview" class="mb-2 max-h-48 rounded-lg">
|
|
<div class="flex flex-wrap gap-2">
|
|
<button type="button" @click="startCamera()" class="rounded-lg bg-slate-100 px-3 py-2 text-sm">Open camera</button>
|
|
<button type="button" @click="capturePhoto()" class="rounded-lg bg-slate-100 px-3 py-2 text-sm">Capture</button>
|
|
<button type="button" @click="clearPhoto()" class="rounded-lg bg-slate-100 px-3 py-2 text-sm">Clear</button>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
<template x-if="field.type === 'textarea'">
|
|
<textarea x-model="form.type_fields[field.name]" :required="field.required" rows="2" class="w-full rounded-xl border-slate-200 px-4 py-3 text-lg"></textarea>
|
|
</template>
|
|
<template x-if="field.type === 'checkbox'">
|
|
<label class="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" :checked="form.type_fields[field.name]" @change="form.type_fields[field.name] = $event.target.checked" :required="field.required" class="h-5 w-5 rounded">
|
|
Yes
|
|
</label>
|
|
</template>
|
|
<template x-if="field.type === 'date' || field.type === 'datetime-local' || field.type === 'text'">
|
|
<input :type="field.type" x-model="form.type_fields[field.name]" :required="field.required" class="w-full rounded-xl border-slate-200 px-4 py-3 text-lg">
|
|
</template>
|
|
<template x-if="field.type === 'signature'">
|
|
<canvas :x-ref="'sig_' + field.name" width="400" height="120" class="w-full rounded-xl border border-slate-200 bg-slate-50"
|
|
@mousedown="startSign($event, field.name)" @mousemove="drawSign($event, field.name)" @mouseup="endSign(field.name)"
|
|
@touchstart.prevent="startSign($event, field.name)" @touchmove.prevent="drawSign($event, field.name)" @touchend.prevent="endSign(field.name)"></canvas>
|
|
<button type="button" @click="clearSign(field.name)" class="mt-1 text-xs text-slate-500">Clear signature</button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="typeConfigs[form.visitor_type]?.requires_approval">
|
|
<p class="rounded-xl bg-amber-50 px-4 py-3 text-sm text-amber-800">Reception will review your visit before issuing a badge.</p>
|
|
</template>
|
|
|
|
<label class="flex items-center gap-3 text-sm">
|
|
<input type="checkbox" x-model="form.policies_accepted" required class="h-5 w-5 rounded">
|
|
I have read and accept the visitor policies
|
|
</label>
|
|
<button type="submit" :disabled="loading" class="w-full rounded-xl bg-teal-600 py-4 text-lg font-bold text-white disabled:opacity-50">
|
|
<span x-text="loading ? 'Submitting…' : 'Complete check-in'"></span>
|
|
</button>
|
|
</form>
|
|
</template>
|
|
|
|
<template x-if="step === 'done'">
|
|
<div class="rounded-3xl bg-white p-8 text-center text-slate-800 shadow-xl">
|
|
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-teal-100 text-4xl">✓</div>
|
|
<h2 class="text-2xl font-bold" x-text="result?.awaiting_approval ? 'Submitted for approval' : 'You\'re checked in!'"></h2>
|
|
<p class="mt-2 text-slate-600" x-text="result?.visitor_name"></p>
|
|
<template x-if="!result?.awaiting_approval">
|
|
<p class="mt-1 text-sm text-slate-500">Badge code: <span class="font-mono font-bold" x-text="result?.badge_code"></span></p>
|
|
</template>
|
|
<p class="mt-4 text-sm text-slate-500" x-text="result?.awaiting_approval ? 'Please wait at reception.' : 'Your host has been notified.'"></p>
|
|
<button @click="reset()" class="mt-8 rounded-xl bg-teal-600 px-8 py-3 font-semibold text-white">Done</button>
|
|
</div>
|
|
</template>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
function kioskFlow() {
|
|
return {
|
|
step: 'welcome',
|
|
loading: false,
|
|
result: null,
|
|
timer: null,
|
|
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: {},
|
|
},
|
|
selectType(type) {
|
|
this.form.visitor_type = type;
|
|
this.form.type_fields = {};
|
|
this.step = 'details';
|
|
this.resetTimer();
|
|
},
|
|
resetTimer() {
|
|
clearTimeout(this.timer);
|
|
this.timer = setTimeout(() => this.reset(), {{ $resetSeconds }} * 1000);
|
|
},
|
|
startTimer() { this.resetTimer(); },
|
|
reset() {
|
|
this.stopCamera();
|
|
this.step = 'welcome';
|
|
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() {
|
|
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>
|