Files
OnAPB-Carrere_Demartin/resources/views/admin/escanear_qr.blade.php
T
Laucha1312 cc049c6cb6 3
2026-06-04 15:20:26 -03:00

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