392 lines
14 KiB
PHP
392 lines
14 KiB
PHP
@extends('admin.layout')
|
|
|
|
@section('title', 'Escanear QR - Admin OnAPB')
|
|
|
|
@section('styles')
|
|
<style>
|
|
.scanner-wrapper {
|
|
max-width: 500px;
|
|
margin: 0 auto;
|
|
}
|
|
#reader {
|
|
width: 100%;
|
|
max-width: 420px;
|
|
height: 420px;
|
|
margin: 0 auto;
|
|
border: 2px solid #dee2e6;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
background: #1a1a2e;
|
|
}
|
|
.evento-badge {
|
|
display: inline-block;
|
|
background: linear-gradient(135deg, #b00000, #d32f2f);
|
|
color: #fff;
|
|
padding: 8px 16px;
|
|
border-radius: 20px;
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
margin-bottom: 16px;
|
|
}
|
|
.qr-result-card {
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
text-align: center;
|
|
font-size: 1.1rem;
|
|
margin-top: 20px;
|
|
animation: fadeInUp 0.3s ease;
|
|
}
|
|
.qr-result-card.success {
|
|
background: #d4edda;
|
|
border: 2px solid #28a745;
|
|
color: #155724;
|
|
}
|
|
.qr-result-card.error {
|
|
background: #f8d7da;
|
|
border: 2px solid #dc3545;
|
|
color: #721c24;
|
|
}
|
|
.qr-result-card.warning {
|
|
background: #fff3cd;
|
|
border: 2px solid #ffc107;
|
|
color: #856404;
|
|
}
|
|
.qr-result-card h4 {
|
|
font-size: 1.3rem;
|
|
margin-bottom: 12px;
|
|
}
|
|
.scanner-btn {
|
|
padding: 12px 28px;
|
|
border-radius: 10px;
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
border: none;
|
|
transition: all 0.2s ease;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.scanner-btn:hover {
|
|
transform: translateY(-2px);
|
|
}
|
|
.manual-input-group {
|
|
max-width: 420px;
|
|
margin: 0 auto;
|
|
}
|
|
@keyframes fadeInUp {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
</style>
|
|
@endsection
|
|
|
|
@section('content')
|
|
<div class="page-header">
|
|
<h2><i class="bi bi-qr-code-scan"></i> Escanear / Validar QR</h2>
|
|
</div>
|
|
|
|
<div class="admin-card">
|
|
<div class="card-body">
|
|
<div class="scanner-wrapper text-center">
|
|
|
|
{{-- 1. Selector de Evento --}}
|
|
<div class="mb-4">
|
|
<label for="eventoSelect" class="form-label fw-bold" style="font-size: 1rem;">
|
|
<i class="bi bi-calendar-event"></i> Seleccionar evento:
|
|
</label>
|
|
<select id="eventoSelect" class="form-select" style="max-width: 420px; margin: 0 auto;">
|
|
<option value="">-- Seleccioná un evento --</option>
|
|
@foreach($eventos as $ev)
|
|
<option value="{{ $ev->id_evento }}"
|
|
data-nombre="{{ $ev->nombre_evento }} ({{ $ev->fecha_evento ? \Carbon\Carbon::parse($ev->fecha_evento)->format('d/m/Y') : '' }} {{ substr($ev->hora_inicio, 0, 5) }})">
|
|
{{ $ev->nombre_evento }} ({{ $ev->fecha_evento ? \Carbon\Carbon::parse($ev->fecha_evento)->format('d/m/Y') : '' }} {{ substr($ev->hora_inicio, 0, 5) }})
|
|
</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
{{-- Badge del evento seleccionado --}}
|
|
<div id="eventoSeleccionado" style="display: none;"></div>
|
|
|
|
<p class="text-muted mb-3">Apuntá con la cámara al código QR o ingresalo manualmente.</p>
|
|
|
|
{{-- 2. Botones de cámara --}}
|
|
<div class="mb-3 d-flex gap-2 justify-content-center">
|
|
<button id="startBtn" class="scanner-btn btn btn-primary d-none">
|
|
<i class="bi bi-camera-video-fill"></i> Iniciar escaneo
|
|
</button>
|
|
<button id="stopBtn" class="scanner-btn btn btn-danger d-none">
|
|
<i class="bi bi-stop-circle-fill"></i> Detener
|
|
</button>
|
|
</div>
|
|
|
|
{{-- 3. Visor de cámara --}}
|
|
<div id="reader" class="d-none"></div>
|
|
|
|
{{-- 4. Input manual (fallback) --}}
|
|
<div class="mt-4">
|
|
<p class="text-muted small mb-2"><i class="bi bi-keyboard"></i> O ingresá el código manualmente:</p>
|
|
<div class="input-group manual-input-group">
|
|
<input type="text" id="qrManualInput" class="form-control form-control-lg" placeholder="Código QR...">
|
|
<button class="btn btn-admin" id="btnManualValidar" onclick="validarManual()">
|
|
<i class="bi bi-search"></i> Validar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 5. Resultado --}}
|
|
<div id="qrResult" style="position: relative; z-index: 10;"></div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Sonidos de feedback --}}
|
|
<audio id="beep-ok" src="{{ asset('static/sounds/beep-ok.mp3') }}" preload="auto"></audio>
|
|
<audio id="beep-error" src="{{ asset('static/sounds/beep-error.mp3') }}" preload="auto"></audio>
|
|
@endsection
|
|
|
|
@section('scripts')
|
|
{{-- Librería html5-qrcode --}}
|
|
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
|
<script>
|
|
let html5QrCode;
|
|
const VALIDAR_URL = '{{ route("admin.validar.qr") }}';
|
|
const CSRF_TOKEN = '{{ csrf_token() }}';
|
|
|
|
// ─────────────────────────────────
|
|
// Evento select → mostrar/ocultar botones
|
|
// ─────────────────────────────────
|
|
document.getElementById('eventoSelect').addEventListener('change', function(e) {
|
|
const startBtn = document.getElementById('startBtn');
|
|
const eventoInfo = document.getElementById('eventoSeleccionado');
|
|
const selected = e.target.options[e.target.selectedIndex];
|
|
|
|
if (e.target.value) {
|
|
startBtn.classList.remove('d-none');
|
|
eventoInfo.innerHTML = '<span class="evento-badge"><i class="bi bi-check-circle-fill"></i> ' + selected.dataset.nombre + '</span>';
|
|
eventoInfo.style.display = 'block';
|
|
} else {
|
|
startBtn.classList.add('d-none');
|
|
eventoInfo.style.display = 'none';
|
|
eventoInfo.innerHTML = '';
|
|
}
|
|
});
|
|
|
|
// ─────────────────────────────────
|
|
// Feedback (sonido + vibración)
|
|
// ─────────────────────────────────
|
|
function playFeedback(success) {
|
|
if (navigator.vibrate) {
|
|
navigator.vibrate(success ? 200 : [100, 100, 200]);
|
|
}
|
|
const audio = document.getElementById(success ? 'beep-ok' : 'beep-error');
|
|
if (audio) {
|
|
audio.currentTime = 0;
|
|
audio.play().catch(() => {});
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────
|
|
// Mostrar resultado (SweetAlert2)
|
|
// ─────────────────────────────────
|
|
function showResult(data) {
|
|
const resultDiv = document.getElementById('qrResult');
|
|
|
|
// Limpiar mensaje anterior del div (opcional)
|
|
resultDiv.innerHTML = '';
|
|
|
|
if (data.valid) {
|
|
playFeedback(true);
|
|
|
|
// Determinar icono y titulo segun tipo
|
|
let icon = 'success';
|
|
let title = '¡QR Válido!';
|
|
let confirmColor = '#2e7d32';
|
|
|
|
if (data.data.tipo === 'libre_50') {
|
|
icon = 'info';
|
|
title = '¡Descuento 50%!';
|
|
confirmColor = '#0277bd';
|
|
}
|
|
|
|
Swal.fire({
|
|
icon: icon,
|
|
title: title,
|
|
iconColor: confirmColor,
|
|
html: `
|
|
<div class="text-start">
|
|
<p class="mb-2"><strong>${data.message}</strong></p>
|
|
<p class="mb-1 small"><b>Titular:</b> ${data.data.titular}</p>
|
|
<p class="mb-1 small"><b>Categoría:</b> ${data.data.categoria || 'N/A'}</p>
|
|
<p class="mb-1 small"><b>Evento:</b> ${data.data.evento}</p>
|
|
<p class="mb-0 small text-muted">Aforo restante: ${data.data.restantes}</p>
|
|
</div>
|
|
`,
|
|
confirmButtonText: 'Siguiente',
|
|
confirmButtonColor: confirmColor,
|
|
timer: 5000,
|
|
timerProgressBar: true
|
|
});
|
|
} else {
|
|
playFeedback(false);
|
|
const isWarning = data.message.includes('⏳');
|
|
const errorColor = isWarning ? '#fbc02d' : '#b00000';
|
|
|
|
Swal.fire({
|
|
icon: isWarning ? 'warning' : 'error',
|
|
title: isWarning ? 'Evento no iniciado' : 'QR Inválido',
|
|
text: data.message,
|
|
iconColor: errorColor,
|
|
confirmButtonText: 'Reintentar',
|
|
confirmButtonColor: errorColor
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────
|
|
// Validar QR (llamada al backend)
|
|
// ─────────────────────────────────
|
|
function validarQr(id_qr) {
|
|
const id_evento = document.getElementById('eventoSelect').value;
|
|
const resultDiv = document.getElementById('qrResult');
|
|
resultDiv.innerHTML = '<div class="text-muted mt-3"><i class="bi bi-hourglass-split"></i> Validando...</div>';
|
|
|
|
fetch(VALIDAR_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': CSRF_TOKEN,
|
|
},
|
|
body: JSON.stringify({ id_qr: id_qr, id_evento: id_evento })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => showResult(data))
|
|
.catch(err => {
|
|
playFeedback(false);
|
|
resultDiv.innerHTML = `
|
|
<div class="qr-result-card error">
|
|
<h4><i class="bi bi-x-circle-fill"></i> Error de conexión</h4>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────
|
|
// Escaneo con cámara
|
|
// ─────────────────────────────────
|
|
function onScanSuccess(decodedText) {
|
|
stopScanner();
|
|
|
|
let id_qr;
|
|
try {
|
|
// Intentar parsear como URL (el QR viejo tiene ?id_qr=...)
|
|
const url = new URL(decodedText);
|
|
id_qr = url.searchParams.get('id_qr');
|
|
} catch (e) {
|
|
// Si no es URL, usar el texto directamente como id_qr
|
|
id_qr = decodedText.trim();
|
|
}
|
|
|
|
if (!id_qr) {
|
|
playFeedback(false);
|
|
document.getElementById('qrResult').innerHTML = `
|
|
<div class="qr-result-card error">
|
|
<h4><i class="bi bi-x-circle-fill"></i> QR inválido</h4>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const id_evento = document.getElementById('eventoSelect').value;
|
|
if (!id_evento) {
|
|
playFeedback(false);
|
|
document.getElementById('qrResult').innerHTML = `
|
|
<div class="qr-result-card warning">
|
|
<h4><i class="bi bi-exclamation-triangle-fill"></i> Seleccioná un evento antes de escanear</h4>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
validarQr(id_qr);
|
|
}
|
|
|
|
function stopScanner() {
|
|
if (html5QrCode) {
|
|
html5QrCode.stop().then(() => {
|
|
document.getElementById('reader').innerHTML = '';
|
|
document.getElementById('reader').classList.add('d-none');
|
|
document.getElementById('startBtn').classList.remove('d-none');
|
|
document.getElementById('stopBtn').classList.add('d-none');
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
// Iniciar
|
|
document.getElementById('startBtn').addEventListener('click', () => {
|
|
// Verificar Contexto Seguro (Indispensable para cámara en móviles)
|
|
if (!window.isSecureContext && window.location.protocol !== 'http:' && window.location.hostname !== 'localhost') {
|
|
playFeedback(false);
|
|
document.getElementById('qrResult').innerHTML = `
|
|
<div class="qr-result-card error">
|
|
<h4><i class="bi bi-shield-lock-fill"></i> Error: Conexión no segura</h4>
|
|
<p>La cámara solo funciona bajo <b>HTTPS</b>. Asegurate de estar usando la URL que empieza con https://.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const readerEl = document.getElementById('reader');
|
|
readerEl.classList.remove('d-none');
|
|
|
|
// Si ya existe instancia previa, limpiarla
|
|
if (html5QrCode) {
|
|
html5QrCode.clear();
|
|
}
|
|
|
|
html5QrCode = new Html5Qrcode('reader');
|
|
|
|
html5QrCode.start(
|
|
{ facingMode: 'environment' },
|
|
{ fps: 10, qrbox: { width: 300, height: 300 } },
|
|
onScanSuccess
|
|
).then(() => {
|
|
document.getElementById('startBtn').classList.add('d-none');
|
|
document.getElementById('stopBtn').classList.remove('d-none');
|
|
}).catch(err => {
|
|
playFeedback(false);
|
|
let errorMsg = err;
|
|
if (err.includes('Permission denied')) errorMsg = "Permiso de cámara denegado. Habilitá la cámara en los ajustes del sitio.";
|
|
if (err.includes('not supported')) errorMsg = "Tu navegador no soporta streaming de cámara o requiere HTTPS.";
|
|
|
|
document.getElementById('qrResult').innerHTML = `
|
|
<div class="qr-result-card error">
|
|
<h4><i class="bi bi-camera-video-off-fill"></i> Error al iniciar cámara</h4>
|
|
<p class="small">${errorMsg}</p>
|
|
<p class="mt-2 small text-muted">Asegurate de usar Chrome o Safari y haber aceptado los permisos.</p>
|
|
</div>
|
|
`;
|
|
});
|
|
});
|
|
|
|
// Detener manual
|
|
document.getElementById('stopBtn').addEventListener('click', stopScanner);
|
|
|
|
// ─────────────────────────────────
|
|
// Validar manual
|
|
// ─────────────────────────────────
|
|
function validarManual() {
|
|
const code = document.getElementById('qrManualInput').value.trim();
|
|
if (!code) return;
|
|
|
|
validarQr(code);
|
|
document.getElementById('qrManualInput').value = '';
|
|
document.getElementById('qrManualInput').focus();
|
|
}
|
|
|
|
document.getElementById('qrManualInput').addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') validarManual();
|
|
});
|
|
</script>
|
|
@endsection
|