Files
whatsapp/verificar.php
T

619 lines
20 KiB
PHP

<?php
/**
* /verificar.php — Validación de paciente por OTP. Página pública, sin autenticación.
*/
require_once __DIR__ . '/config/config.php';
// Leer brand desde DB
$brandColor = '#1565c0';
$brandDark = '#0d47a1';
$logoBase64 = '';
$labNombre = 'Laboratorio';
try {
$cfgs = Database::getInstance()->fetchAll(
"SELECT clave, valor FROM lab_config WHERE clave IN ('doc_color','doc_logo_base64','empresa_nombre')"
);
foreach ($cfgs as $r) {
if ($r['clave'] === 'doc_color' && preg_match('/^#[0-9a-fA-F]{3,8}$/', $r['valor'])) {
$brandColor = $r['valor'];
} elseif ($r['clave'] === 'doc_logo_base64') {
$logoBase64 = $r['valor'];
} elseif ($r['clave'] === 'empresa_nombre') {
$labNombre = $r['valor'];
}
}
$hex = ltrim($brandColor, '#');
$brandDark = sprintf('#%02x%02x%02x',
max(0, hexdec(substr($hex,0,2)) - 40),
max(0, hexdec(substr($hex,2,2)) - 40),
max(0, hexdec(substr($hex,4,2)) - 40)
);
} catch (\Throwable $_) {}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verificación de Paciente — <?= htmlspecialchars($labNombre) ?></title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--brand: <?= $brandColor ?>;
--brand-dark: <?= $brandDark ?>;
}
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--brand-dark) 0%, var(--brand) 50%, color-mix(in srgb, var(--brand) 70%, #7c3aed) 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
padding: 20px;
position: relative;
overflow: hidden;
}
/* Decoración de fondo */
body::before, body::after {
content: '';
position: fixed;
border-radius: 50%;
background: rgba(255,255,255,.06);
pointer-events: none;
}
body::before { width: 500px; height: 500px; top: -120px; right: -120px; }
body::after { width: 400px; height: 400px; bottom: -100px; left: -100px; }
/* Card principal */
.card {
background: rgba(255,255,255,.97);
backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 32px 80px rgba(0,0,0,.25), 0 0 0 1px rgba(255,255,255,.2);
width: 100%;
max-width: 420px;
overflow: hidden;
position: relative;
z-index: 1;
}
/* Header de la card */
.card-header {
background: linear-gradient(135deg, var(--brand-dark), var(--brand));
padding: 32px 28px 24px;
text-align: center;
position: relative;
}
.card-header::after {
content: '';
position: absolute;
bottom: -1px; left: 0; right: 0;
height: 24px;
background: rgba(255,255,255,.97);
border-radius: 24px 24px 0 0;
}
.logo-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,.92);
border-radius: 16px;
padding: 10px 16px;
margin-bottom: 14px;
}
.logo-wrap img { max-height: 52px; max-width: 160px; object-fit: contain; }
.logo-wrap .logo-fallback { font-size: 2rem; color: var(--brand); }
.card-header h1 {
color: #fff;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -.01em;
position: relative;
z-index: 1;
}
.card-header p {
color: rgba(255,255,255,.75);
font-size: .8rem;
margin-top: 4px;
position: relative;
z-index: 1;
}
/* Cuerpo */
.card-body { padding: 28px 28px 32px; }
/* Steps */
.step { display: none; }
.step.active { display: block; animation: fadeUp .3s ease; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Step indicator */
.steps-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 24px;
}
.step-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #e2e8f0;
transition: all .3s ease;
}
.step-dot.done { background: var(--brand); transform: scale(1); }
.step-dot.active { background: var(--brand); transform: scale(1.4); }
.step-line {
flex: 1; max-width: 40px; height: 2px;
background: #e2e8f0;
border-radius: 1px;
transition: background .3s;
}
.step-line.done { background: var(--brand); }
/* Iconos de paso */
.step-icon {
width: 64px; height: 64px;
border-radius: 50%;
background: color-mix(in srgb, var(--brand) 12%, #fff);
display: flex; align-items: center; justify-content: center;
font-size: 1.6rem;
color: var(--brand);
margin: 0 auto 16px;
}
.step-title {
font-size: 1.05rem;
font-weight: 700;
color: #1e293b;
text-align: center;
margin-bottom: 6px;
}
.step-desc {
font-size: .82rem;
color: #64748b;
text-align: center;
line-height: 1.55;
margin-bottom: 22px;
}
.highlight { color: var(--brand); font-weight: 600; }
/* Inputs */
.input-wrap { position: relative; margin-bottom: 14px; }
.input-wrap i {
position: absolute;
left: 14px; top: 50%; transform: translateY(-50%);
color: #94a3b8; font-size: .9rem;
pointer-events: none;
}
.input-field {
width: 100%;
padding: 14px 14px 14px 40px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: .95rem;
color: #1e293b;
background: #f8faff;
outline: none;
transition: border-color .2s, box-shadow .2s;
}
.input-field:focus {
border-color: var(--brand);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand) 15%, transparent);
background: #fff;
}
/* OTP input especial */
.otp-wrap {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 14px;
}
.otp-digit {
width: 48px; height: 58px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: 1.5rem;
font-weight: 700;
text-align: center;
color: var(--brand);
background: #f8faff;
outline: none;
transition: border-color .2s, box-shadow .2s, transform .1s;
}
.otp-digit:focus {
border-color: var(--brand);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand) 15%, transparent);
background: #fff;
transform: scale(1.05);
}
/* Botón principal */
.btn-primary {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, var(--brand-dark), var(--brand));
color: #fff;
border: none;
border-radius: 12px;
font-size: .95rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity .2s, transform .15s;
margin-top: 4px;
}
.btn-primary:hover:not(:disabled) { opacity: .9; transform: translateY(-1px); }
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
/* Botón secundario */
.btn-link {
background: none; border: none;
color: var(--brand); font-size: .82rem;
font-weight: 600; cursor: pointer;
text-decoration: underline;
margin-top: 12px;
display: block;
text-align: center;
}
.btn-link:disabled { color: #94a3b8; text-decoration: none; cursor: default; }
/* Timer */
.timer {
text-align: center;
font-size: .8rem;
color: #94a3b8;
margin-top: 8px;
}
.timer span { color: var(--brand); font-weight: 700; }
/* Error */
.error-msg {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
padding: 10px 14px;
font-size: .82rem;
color: #dc2626;
margin-bottom: 12px;
display: none;
align-items: center;
gap: 8px;
}
.error-msg.show { display: flex; }
/* Éxito */
.success-icon {
width: 80px; height: 80px;
border-radius: 50%;
background: #dcfce7;
display: flex; align-items: center; justify-content: center;
font-size: 2.2rem;
color: #16a34a;
margin: 0 auto 20px;
animation: pop .4s cubic-bezier(.175,.885,.32,1.275);
}
@keyframes pop {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* Spinner */
.spinner { animation: spin .7s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Footer */
.card-footer {
text-align: center;
padding: 0 28px 20px;
font-size: .75rem;
color: #94a3b8;
}
</style>
</head>
<body>
<div class="card">
<!-- Header -->
<div class="card-header">
<div class="logo-wrap">
<?php if ($logoBase64): ?>
<img src="<?= htmlspecialchars($logoBase64) ?>" alt="Logo">
<?php else: ?>
<span class="logo-fallback"><i class="fas fa-flask"></i></span>
<?php endif; ?>
</div>
<h1><?= htmlspecialchars($labNombre) ?></h1>
<p>Portal de verificación de pacientes</p>
</div>
<!-- Body -->
<div class="card-body">
<!-- Indicador de pasos -->
<div class="steps-indicator">
<div class="step-dot active" id="dot-1"></div>
<div class="step-line" id="line-1"></div>
<div class="step-dot" id="dot-2"></div>
<div class="step-line" id="line-2"></div>
<div class="step-dot" id="dot-3"></div>
</div>
<!-- Error global -->
<div class="error-msg" id="error-msg">
<i class="fas fa-exclamation-circle"></i>
<span id="error-text"></span>
</div>
<!-- PASO 1: Cédula -->
<div class="step active" id="step-1">
<div class="step-icon"><i class="fas fa-id-card"></i></div>
<div class="step-title">Ingresa tu documento</div>
<div class="step-desc">Escribe tu número de cédula para buscar tu registro y enviarte un código de verificación.</div>
<div class="input-wrap">
<i class="fas fa-id-badge"></i>
<input type="text" class="input-field" id="inp-cedula"
placeholder="Ej: 12345678" maxlength="12"
inputmode="numeric" pattern="\d+"
onkeydown="if(event.key==='Enter') enviarOtp()">
</div>
<button class="btn-primary" id="btn-enviar" onclick="enviarOtp()">
<i class="fas fa-paper-plane"></i> Enviar código
</button>
</div>
<!-- PASO 2: OTP -->
<div class="step" id="step-2">
<div class="step-icon"><i class="fas fa-mobile-alt"></i></div>
<div class="step-title">Código de verificación</div>
<div class="step-desc" id="desc-otp">
Enviamos un código de 6 dígitos al número <span class="highlight" id="tel-masked">***</span> registrado en tu ficha.
</div>
<div class="otp-wrap" id="otp-wrap">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
<input class="otp-digit" maxlength="1" inputmode="numeric" pattern="\d">
</div>
<button class="btn-primary" id="btn-verificar" onclick="verificarOtp()">
<i class="fas fa-check-circle"></i> Verificar
</button>
<div class="timer">Código válido por <span id="countdown">5:00</span></div>
<button class="btn-link" id="btn-reenviar" onclick="reenviarOtp()" disabled>Reenviar código</button>
</div>
<!-- PASO 3: Éxito -->
<div class="step" id="step-3">
<div class="success-icon"><i class="fas fa-check"></i></div>
<div class="step-title">¡Verificación exitosa!</div>
<div class="step-desc">
Hola, <span class="highlight" id="nombre-paciente"></span>. Tu identidad ha sido verificada correctamente.
Puedes proceder con tu atención.
</div>
<button class="btn-primary" onclick="reiniciar()" style="margin-top:8px">
<i class="fas fa-rotate-left"></i> Nueva verificación
</button>
</div>
</div><!-- /card-body -->
<div class="card-footer">
Laboratorio Ximena Caicedo &copy; <?= date('Y') ?>
</div>
</div>
<script>
let _cedula = '';
let _nombre = '';
let _timer = null;
let _reenvioTimer = null;
// ── OTP input navegación automática ──────────────────────────────────
document.querySelectorAll('.otp-digit').forEach((inp, i, arr) => {
inp.addEventListener('input', () => {
const val = inp.value.replace(/\D/g, '');
if (val.length > 1) {
// Pega múltiples dígitos (mobile/autofill)
const digits = val.slice(0, 6);
arr.forEach((d, j) => { d.value = digits[j] || ''; });
const last = Math.min(digits.length - 1, arr.length - 1);
arr[last].focus();
if (otpCompleto()) verificarOtp();
return;
}
inp.value = val;
if (inp.value && i < arr.length - 1) arr[i + 1].focus();
if (otpCompleto()) verificarOtp();
});
inp.addEventListener('keydown', e => {
if (e.key === 'Backspace' && !inp.value && i > 0) arr[i - 1].focus();
});
inp.addEventListener('paste', e => {
e.preventDefault();
const txt = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g,'').slice(0,6);
arr.forEach((d, j) => { d.value = txt[j] || ''; });
const last = Math.min(txt.length - 1, arr.length - 1);
if (last >= 0) arr[last].focus();
if (txt.length === 6) verificarOtp();
});
});
function otpCompleto() {
return [...document.querySelectorAll('.otp-digit')].every(d => d.value);
}
function getOtp() {
return [...document.querySelectorAll('.otp-digit')].map(d => d.value).join('');
}
function clearOtp() {
document.querySelectorAll('.otp-digit').forEach(d => d.value = '');
document.querySelectorAll('.otp-digit')[0].focus();
}
// ── Mostrar paso ──────────────────────────────────────────────────────
function irAPaso(n) {
[1,2,3].forEach(i => document.getElementById('step-'+i).classList.toggle('active', i===n));
[1,2,3].forEach(i => {
document.getElementById('dot-'+i).classList.toggle('active', i===n);
document.getElementById('dot-'+i).classList.toggle('done', i<n);
});
[1,2].forEach(i => document.getElementById('line-'+i).classList.toggle('done', i<n));
hideError();
}
// ── Error ─────────────────────────────────────────────────────────────
function showError(msg) {
const el = document.getElementById('error-msg');
document.getElementById('error-text').textContent = msg;
el.classList.add('show');
}
function hideError() { document.getElementById('error-msg').classList.remove('show'); }
// ── Enviar OTP ────────────────────────────────────────────────────────
async function enviarOtp() {
const cedula = document.getElementById('inp-cedula').value.trim();
if (!cedula || !/^\d{4,12}$/.test(cedula)) { showError('Ingresa un número de documento válido.'); return; }
const btn = document.getElementById('btn-enviar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-circle-notch spinner"></i> Enviando…';
hideError();
try {
const r = await fetch('api/public/send_otp.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cedula }),
});
const d = await r.json();
if (!d.ok) { showError(d.error || 'Error al enviar el código.'); return; }
_cedula = cedula;
_nombre = d.nombre || '';
document.getElementById('tel-masked').textContent = d.telefono || '***';
irAPaso(2);
iniciarTimer(d.expira || 300);
clearOtp();
iniciarReenvioTimer();
} catch(_) {
showError('No se pudo conectar. Intenta de nuevo.');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Enviar código';
}
}
// ── Verificar OTP ─────────────────────────────────────────────────────
async function verificarOtp() {
if (!otpCompleto()) return;
const btn = document.getElementById('btn-verificar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-circle-notch spinner"></i> Verificando…';
hideError();
try {
const r = await fetch('api/public/verify_otp.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cedula: _cedula, codigo: getOtp() }),
});
const d = await r.json();
if (!d.ok) {
showError(d.error || 'Código incorrecto.');
clearOtp();
return;
}
clearInterval(_timer);
clearTimeout(_reenvioTimer);
document.getElementById('nombre-paciente').textContent = d.nombre || _nombre;
irAPaso(3);
} catch(_) {
showError('No se pudo conectar. Intenta de nuevo.');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check-circle"></i> Verificar';
}
}
// ── Reenviar ──────────────────────────────────────────────────────────
async function reenviarOtp() {
document.getElementById('btn-reenviar').disabled = true;
hideError();
clearOtp();
try {
const r = await fetch('api/public/send_otp.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cedula: _cedula }),
});
const d = await r.json();
if (!d.ok) { showError(d.error || 'Error al reenviar.'); return; }
iniciarTimer(d.expira || 300);
iniciarReenvioTimer();
} catch(_) {
showError('No se pudo reenviar. Intenta de nuevo.');
}
}
// ── Timer cuenta regresiva ────────────────────────────────────────────
function iniciarTimer(segundos) {
clearInterval(_timer);
let s = segundos;
const el = document.getElementById('countdown');
function tick() {
const m = Math.floor(s / 60);
const ss = s % 60;
el.textContent = m + ':' + String(ss).padStart(2,'0');
if (s <= 0) { clearInterval(_timer); el.textContent = 'expirado'; }
s--;
}
tick();
_timer = setInterval(tick, 1000);
}
function iniciarReenvioTimer() {
const btn = document.getElementById('btn-reenviar');
btn.disabled = true;
let s = 60;
clearTimeout(_reenvioTimer);
function tick() {
btn.textContent = `Reenviar código (${s}s)`;
if (s <= 0) { btn.textContent = 'Reenviar código'; btn.disabled = false; return; }
s--;
_reenvioTimer = setTimeout(tick, 1000);
}
tick();
}
// ── Reiniciar ─────────────────────────────────────────────────────────
function reiniciar() {
_cedula = '';
_nombre = '';
clearInterval(_timer);
clearTimeout(_reenvioTimer);
document.getElementById('inp-cedula').value = '';
irAPaso(1);
}
// Focus inicial
document.getElementById('inp-cedula').focus();
</script>
</body>
</html>