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:
co-authored by
Claude Sonnet 4.6
parent
2fc33d22b2
commit
ae8d2cb13a
@@ -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,
|
||||
]);
|
||||
@@ -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
@@ -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 © <?= 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>
|
||||
Reference in New Issue
Block a user