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