feat: página pública de verificación de paciente por OTP (SMS)

verificar.php:
- Pública, sin autenticación ni menú lateral
- Diseño moderno: card glassmorphism sobre gradiente brand
- Flujo en 3 pasos con indicador animado
- Paso 1: ingresa cédula → busca paciente
- Paso 2: inputs OTP de 6 dígitos con navegación automática
  y pegado desde portapapeles; cuenta regresiva + reenvío a los 60s
- Paso 3: pantalla de éxito con animación

api/public/send_otp.php:
- Busca paciente por cédula, genera OTP aleatorio de 6 dígitos
- Guarda en lab_otp_tokens con expiración 5 min
- Envía SMS via WEBSms usando config de lab_config
- Retorna teléfono enmascarado (3**1234**67)

api/public/verify_otp.php:
- Valida cedula+codigo contra lab_otp_tokens activos
- Marca OTP como usado y paciente como verificado
  (telefono_verificado=1, verificado_at=NOW())

DB:
- lab_pacientes: +telefono_verificado, +verificado_at
- lab_otp_tokens: tabla nueva para almacenar OTPs temporales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-24 00:39:30 -05:00
co-authored by Claude Sonnet 4.6
parent 2fc33d22b2
commit ae8d2cb13a
3 changed files with 766 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
<?php
/**
* POST /api/public/send_otp.php
* Público — no requiere autenticación.
* Body JSON: { cedula: "12345678" }
* Busca el paciente, genera OTP de 6 dígitos y lo envía por SMS.
*/
ob_start();
header('Content-Type: application/json; charset=utf-8');
error_reporting(0);
require_once __DIR__ . '/../../config/config.php';
function jpubOk(array $data = []): void {
ob_clean();
echo json_encode(['ok' => true] + $data);
exit;
}
function jpubErr(string $msg, int $code = 400): void {
ob_clean();
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jpubErr('Método no permitido', 405);
$raw = file_get_contents('php://input');
$datos = json_decode($raw, true);
$cedula = trim($datos['cedula'] ?? '');
if (!$cedula || !preg_match('/^\d{4,12}$/', $cedula)) jpubErr('Número de documento inválido.');
try {
$db = Database::getInstance();
$pac = $db->fetch(
"SELECT id, nombre_completo, telefono FROM lab_pacientes WHERE numero_documento = ? AND is_active = 1 LIMIT 1",
[$cedula]
);
} catch (\Throwable $e) { jpubErr('Error de base de datos.', 500); }
if (!$pac) jpubErr('Paciente no encontrado con ese documento.');
if (!$pac['telefono']) jpubErr('El paciente no tiene teléfono registrado. Acércate a recepción.');
// Leer config SMS
try {
$cfgs = $db->fetchAll("SELECT clave, valor FROM lab_config WHERE clave IN ('sms_url','sms_api_key','sms_activo')");
$cfg = array_column($cfgs, 'valor', 'clave');
} catch (\Throwable $e) { jpubErr('Error de configuración.', 500); }
if (($cfg['sms_activo'] ?? '0') !== '1') jpubErr('El servicio de SMS no está activo.');
$smsUrl = rtrim($cfg['sms_url'] ?? '', '/');
$smsKey = $cfg['sms_api_key'] ?? '';
if (!$smsUrl || !$smsKey) jpubErr('SMS no configurado.');
// Invalidar OTPs anteriores del mismo paciente
try {
$db->query("UPDATE lab_otp_tokens SET usado = 1 WHERE paciente_id = ? AND usado = 0", [$pac['id']]);
} catch (\Throwable $_) {}
// Generar OTP
$codigo = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$expires = date('Y-m-d H:i:s', time() + 300); // 5 min
try {
$db->query(
"INSERT INTO lab_otp_tokens (paciente_id, codigo, expires_at) VALUES (?, ?, ?)",
[$pac['id'], $codigo, $expires]
);
} catch (\Throwable $e) { jpubErr('No se pudo generar el código.', 500); }
// Enviar SMS
$numero = preg_replace('/\D/', '', $pac['telefono']);
$mensaje = "Tu código de verificación de Laboratorio es: {$codigo}. Válido por 5 minutos.";
$payload = json_encode(['numero' => $numero, 'mensaje' => $mensaje], JSON_UNESCAPED_UNICODE);
$ch = curl_init($smsUrl . '/api/sms/send');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $smsKey],
CURLOPT_TIMEOUT => 12,
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($curlErr || $httpCode >= 400) {
$smsResp = json_decode($resp, true);
jpubErr('No se pudo enviar el SMS: ' . ($smsResp['error'] ?? "HTTP {$httpCode}"), 502);
}
// Enmascarar teléfono para mostrar en UI: 3001****67
$tel = $pac['telefono'];
$digits = preg_replace('/\D/', '', $tel);
$masked = substr($digits, 0, 3) . str_repeat('*', max(0, strlen($digits) - 5)) . substr($digits, -2);
jpubOk([
'nombre' => explode(' ', $pac['nombre_completo'])[0],
'telefono' => $masked,
'expira' => 300,
]);
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* POST /api/public/verify_otp.php
* Público — no requiere autenticación.
* Body JSON: { cedula: "12345678", codigo: "123456" }
*/
ob_start();
header('Content-Type: application/json; charset=utf-8');
error_reporting(0);
require_once __DIR__ . '/../../config/config.php';
function jpubOk(array $data = []): void { ob_clean(); echo json_encode(['ok' => true] + $data); exit; }
function jpubErr(string $msg, int $code = 400): void {
ob_clean(); http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg]); exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jpubErr('Método no permitido', 405);
$datos = json_decode(file_get_contents('php://input'), true);
$cedula = trim($datos['cedula'] ?? '');
$codigo = trim($datos['codigo'] ?? '');
if (!$cedula) jpubErr('Documento requerido.');
if (!preg_match('/^\d{6}$/', $codigo)) jpubErr('Código inválido.');
try {
$db = Database::getInstance();
$pac = $db->fetch("SELECT id, nombre_completo FROM lab_pacientes WHERE numero_documento = ? AND is_active = 1", [$cedula]);
} catch (\Throwable $_) { jpubErr('Error de base de datos.', 500); }
if (!$pac) jpubErr('Paciente no encontrado.');
// Buscar OTP válido
try {
$otp = $db->fetch(
"SELECT id FROM lab_otp_tokens
WHERE paciente_id = ? AND codigo = ? AND usado = 0 AND expires_at > NOW()
ORDER BY id DESC LIMIT 1",
[$pac['id'], $codigo]
);
} catch (\Throwable $_) { jpubErr('Error de base de datos.', 500); }
if (!$otp) jpubErr('Código incorrecto o expirado. Solicita uno nuevo.');
// Marcar OTP como usado y paciente como verificado
try {
$db->query("UPDATE lab_otp_tokens SET usado = 1 WHERE id = ?", [$otp['id']]);
$db->query(
"UPDATE lab_pacientes SET telefono_verificado = 1, verificado_at = NOW() WHERE id = ?",
[$pac['id']]
);
} catch (\Throwable $_) { jpubErr('Error al guardar verificación.', 500); }
jpubOk(['nombre' => explode(' ', $pac['nombre_completo'])[0]]);
+606
View File
@@ -0,0 +1,606 @@
<?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', () => {
inp.value = inp.value.replace(/\D/g, '').slice(-1);
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] || ''; });
if (txt.length === 6) { arr[5].focus(); 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>