feat: Stripe Checkout, Zeptomail, pre-alert linking, perfil 3-tab, carga-pesada landing

- payments: real Stripe Checkout Session (redirect flow), confirmBySession, webhook handler
- notifications: Zeptomail REST email, wa.me WhatsApp links, IntegrationsService injection
- auth: changePassword, updateProfile, disableMfa (TOTP-verified) endpoints
- packages: tryLinkPreAlert() auto-links on create (non-blocking)
- integrations: catalog updated (zeptomail, stripe_webhook_secret; removed sendgrid/whatsapp-api)
- web/portal/perfil: 3-tab layout (datos / contraseña / MFA)
- web/portal/pago: Stripe redirect + ?success=1&session_id= callback, cancelled banner
- web/admin/b2b: fix enum values (EN_COTIZACION, ACEPTADO)
- web/carga-pesada: rich marketing landing (FCL/LCL, 7-step process, sectors, INEN callout)
- tests: fix payments.service.spec (IntegrationsService+ConfigService mocks)
- tests: fix notifications.service.spec (IntegrationsService mock, phone in mockUser)
- all 104 tests passing, API + web builds clean
This commit is contained in:
Lizandro Guarnizo
2026-06-01 20:24:35 -05:00
parent b2f03e654f
commit 98ab5a309c
19 changed files with 979 additions and 134 deletions
+15 -15
View File
@@ -5,26 +5,26 @@ import { Timestamp } from "@/app/_components/timestamp";
// §13 — Flujo del Importador Mayorista (B2B)
const STATUS_LABEL: Record<string, string> = {
PENDIENTE: "Pendiente",
CONTACTADO: "Contactado",
COTIZADO: "Cotizado",
APROBADO: "Aprobado",
EN_PROCESO: "En proceso",
COMPLETADO: "Completado",
CANCELADO: "Cancelado",
PENDIENTE: "Pendiente",
EN_COTIZACION: "En cotización",
COTIZADO: "Cotizado",
ACEPTADO: "Aceptado",
EN_PROCESO: "En proceso",
COMPLETADO: "Completado",
CANCELADO: "Cancelado",
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow",
CONTACTADO: "badge-blue",
COTIZADO: "badge-blue",
APROBADO: "badge-green",
EN_PROCESO: "badge-orange",
COMPLETADO: "badge-green",
CANCELADO: "badge-red",
PENDIENTE: "badge-yellow",
EN_COTIZACION: "badge-blue",
COTIZADO: "badge-blue",
ACEPTADO: "badge-green",
EN_PROCESO: "badge-orange",
COMPLETADO: "badge-green",
CANCELADO: "badge-red",
};
const STATUS_FLOW = ["PENDIENTE", "CONTACTADO", "COTIZADO", "APROBADO", "EN_PROCESO", "COMPLETADO", "CANCELADO"];
const STATUS_FLOW = ["PENDIENTE", "EN_COTIZACION", "COTIZADO", "ACEPTADO", "EN_PROCESO", "COMPLETADO", "CANCELADO"];
export default function AdminB2BPage() {
const [requests, setRequests] = useState<any[]>([]);
+181 -22
View File
@@ -1,52 +1,211 @@
import Link from "next/link";
const SERVICES = [
{
icon: "🚢",
title: "Contenedor completo (FCL)",
desc: "Importación de un contenedor completo de 20' o 40'. Ideal para grandes volúmenes de mercancía homogénea.",
details: ["Contenedor 20' (~26 m³)", "Contenedor 40' (~67 m³)", "Coordinación puerta a puerta", "Gestión aduanera DAI completa"],
badge: "B2B",
},
{
icon: "📦",
title: "Carga consolidada (LCL)",
desc: "Comparte el espacio de un contenedor con otros importadores. Paga solo por el m³ que usas.",
details: ["Mínimo 1 CBM", "Frecuencia semanal NJ → Ecuador", "Consolidación en nuestra bodega NJ", "Trazabilidad en tiempo real"],
badge: "Más flexible",
},
{
icon: "🏗️",
title: "Pallets y carga suelta",
desc: "Equipos, maquinaria, repuestos industriales y artículos de gran volumen o peso.",
details: ["Hasta 2,500 kg por pallet", "Flejado y embalaje en NJ", "Seguro de carga incluido", "INEN y permisos SENAE"],
badge: "",
},
{
icon: "🚗",
title: "Vehículos y maquinaria",
desc: "Importación de automóviles, maquinaria agrícola, equipos industriales y vehículos especiales.",
details: ["RO-RO y contenedor flat rack", "Inspección pre-embarque", "Gestión de homologación SENAE", "Coordinación con INEN y ANT"],
badge: "",
},
];
const SECTORS = [
{ icon: "👗", label: "Textil y confección" },
{ icon: "👟", label: "Calzado (INEN obligatorio)" },
{ icon: "⚙️", label: "Maquinaria industrial" },
{ icon: "🏠", label: "Muebles y decoración" },
{ icon: "🍎", label: "Alimentos y bebidas" },
{ icon: "💻", label: "Electrónica y tecnología" },
{ icon: "🔧", label: "Repuestos automotrices" },
{ icon: "🧴", label: "Cosméticos y cuidado personal" },
];
const PROCESS = [
{ n: 1, title: "Solicita cotización", desc: "Completa el formulario con el tipo de mercancía, volumen y ciudad de origen en EE.UU.", color: "var(--primary)" },
{ n: 2, title: "Recibe propuesta", desc: "Nuestro equipo te envía una propuesta detallada con costos de flete, seguro, SENAE e INEN en 2448 horas.", color: "var(--accent)" },
{ n: 3, title: "Acepta y prepara la carga", desc: "Coordinas con tu proveedor en EE.UU. el despacho a nuestra bodega en New Jersey.", color: "var(--primary)" },
{ n: 4, title: "Recibimos y verificamos", desc: "Inspeccionamos, pesamos, fotografiamos y consolidamos tu carga. Tramitamos permisos INEN y certificados.", color: "var(--accent)" },
{ n: 5, title: "Despacho internacional", desc: "Embarcamos tu carga vía aérea (urgente) o marítima (económica) con tracking en tiempo real.", color: "var(--primary)" },
{ n: 6, title: "Gestión aduanera Ecuador", desc: "Nuestros agentes autorizados SENAE tramitan la DAI completa. Sin sorpresas en aduana.", color: "var(--accent)" },
{ n: 7, title: "Entrega en tu destino", desc: "Coordinamos la entrega en bodega o puerta a puerta en cualquier ciudad del Ecuador.", color: "var(--primary)" },
];
export default function CargaPesadaPage() {
return (
<div style={{ minHeight: "100vh" }}>
<div style={{ minHeight: "100vh", background: "var(--gray-50)" }}>
{/* Navbar */}
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/como-funciona" className="navbar-link">Cómo funciona</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
<Link href="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-sm">Cotizar B2B</Link>
</div>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
<div className="container">
<h1 style={{ fontSize: "clamp(1.75rem,4vw,2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>Carga Pesada y B2B</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 600, margin: "0 auto" }}>
Importaciones de volumen para empresas y emprendedores. Maquinaria, muebles, vehículos y más.
{/* Hero */}
<div style={{ background: "linear-gradient(135deg, #0a0f2e 0%, #001F5B 60%, #0f3a8a 100%)", color: "white", padding: "5rem 0 4rem", position: "relative", overflow: "hidden" }}>
<div style={{ position: "absolute", top: 0, right: 0, width: 400, height: 400, background: "radial-gradient(circle, rgba(255,107,0,.15) 0%, transparent 70%)", pointerEvents: "none" }} />
<div className="container" style={{ position: "relative", zIndex: 1, textAlign: "center" }}>
<div style={{ display: "inline-flex", gap: ".5rem", marginBottom: "1.5rem", flexWrap: "wrap", justifyContent: "center" }}>
{["FCL / LCL", "Pallets", "Maquinaria", "SENAE Autorizado", "INEN"].map(b => (
<span key={b} style={{ background: "rgba(255,107,0,.2)", color: "#FDB87A", fontSize: ".72rem", fontWeight: 700, padding: "4px 12px", borderRadius: 999 }}>{b}</span>
))}
</div>
<h1 style={{ fontSize: "clamp(2rem,5vw,3rem)", fontWeight: 900, lineHeight: 1.15, marginBottom: "1.25rem" }}>
Carga Pesada<br />
<span style={{ color: "#FF6B00" }}>EE.UU. Ecuador</span>
</h1>
<p style={{ color: "rgba(255,255,255,.75)", fontSize: "1.1rem", maxWidth: 580, margin: "0 auto 2.5rem", lineHeight: 1.7 }}>
Importación de volumen para empresas y mayoristas. Contenedores, pallets, maquinaria industrial.
Gestión aduanera DAI completa con agentes SENAE autorizados.
</p>
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-lg">
Solicitar cotización
</Link>
<Link href="/carga-pesada/como-funciona" className="btn btn-outline btn-lg" style={{ color: "white", borderColor: "rgba(255,255,255,.3)" }}>
Ver proceso
</Link>
</div>
</div>
</div>
<div className="section">
{/* Servicios */}
<div className="section" style={{ background: "white" }}>
<div className="container">
<div className="grid-3" style={{ marginBottom: "3rem" }}>
{[
{ icon: "🚢", title: "Carga marítima FCL/LCL", desc: "Contenedores completos o carga consolidada desde puertos de EE.UU. a Ecuador (Guayaquil / Manta)." },
{ icon: "✈️", title: "Flete aéreo express", desc: "Para carga urgente o mercancía de alto valor. Tiempo de tránsito 35 días." },
{ icon: "🏭", title: "Soluciones B2B", desc: "Gestión de importaciones recurrentes para empresas. Precios preferenciales por volumen." },
].map(s => (
<div key={s.title} className="card" style={{ padding: "2rem" }}>
<div style={{ textAlign: "center", marginBottom: "3rem" }}>
<div style={{ fontSize: ".85rem", fontWeight: 700, color: "var(--primary)", textTransform: "uppercase", letterSpacing: "2px", marginBottom: ".75rem" }}>Nuestros servicios</div>
<h2 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 800, marginBottom: "1rem" }}>Soluciones para cada tipo de carga</h2>
<p style={{ color: "var(--gray-500)", maxWidth: 560, margin: "0 auto" }}>
Desde un pallet hasta un contenedor completo. Coordinamos cada detalle logístico y aduanero.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1.5rem" }}>
{SERVICES.map(s => (
<div key={s.title} className="card" style={{ padding: "1.75rem", position: "relative" }}>
{s.badge && (
<span className="badge badge-blue" style={{ position: "absolute", top: "1rem", right: "1rem", fontSize: ".7rem" }}>{s.badge}</span>
)}
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>{s.icon}</div>
<h3 style={{ fontWeight: 700, marginBottom: ".75rem" }}>{s.title}</h3>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{s.desc}</p>
<h3 style={{ fontWeight: 700, fontSize: "1.05rem", marginBottom: ".6rem" }}>{s.title}</h3>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem", lineHeight: 1.6, marginBottom: "1rem" }}>{s.desc}</p>
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: ".35rem" }}>
{s.details.map(d => (
<li key={d} style={{ fontSize: ".82rem", color: "var(--gray-600)", display: "flex", gap: ".4rem", alignItems: "flex-start" }}>
<span style={{ color: "var(--green)", marginTop: "2px", flexShrink: 0 }}></span>
{d}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
<div style={{ textAlign: "center" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>¿Necesitas importar en volumen?</h2>
<p style={{ color: "var(--gray-500)", marginBottom: "2rem" }}>Completa el formulario y un asesor te contactará en menos de 24 horas.</p>
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-xl">Solicitar cotización </Link>
{/* Proceso */}
<div className="section-gray">
<div className="container" style={{ maxWidth: 800 }}>
<div style={{ textAlign: "center", marginBottom: "3rem" }}>
<div style={{ fontSize: ".85rem", fontWeight: 700, color: "var(--primary)", textTransform: "uppercase", letterSpacing: "2px", marginBottom: ".75rem" }}>Proceso</div>
<h2 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 800 }}>De NJ a Ecuador en 7 pasos</h2>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{PROCESS.map((p, i) => (
<div key={p.n} style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0 }}>
<div style={{ width: 44, height: 44, borderRadius: "50%", background: p.color, color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: "1rem" }}>{p.n}</div>
{i < PROCESS.length - 1 && <div style={{ width: 2, height: 36, background: "var(--gray-200)", marginTop: ".25rem" }} />}
</div>
<div className="card" style={{ flex: 1, padding: "1rem 1.25rem" }}>
<div style={{ fontWeight: 700, fontSize: ".95rem", marginBottom: ".3rem" }}>{p.title}</div>
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", lineHeight: 1.6 }}>{p.desc}</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Sectores */}
<div className="section" style={{ background: "white" }}>
<div className="container">
<div style={{ textAlign: "center", marginBottom: "2.5rem" }}>
<h2 style={{ fontSize: "1.75rem", fontWeight: 800, marginBottom: ".75rem" }}>Sectores que atendemos</h2>
<p style={{ color: "var(--gray-500)" }}>Experiencia en los rubros con mayor volumen de importación EcuadorEE.UU.</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "1rem" }}>
{SECTORS.map(s => (
<div key={s.label} className="card" style={{ padding: "1.25rem", textAlign: "center" }}>
<div style={{ fontSize: "2rem", marginBottom: ".5rem" }}>{s.icon}</div>
<div style={{ fontSize: ".85rem", fontWeight: 600, color: "var(--gray-700)" }}>{s.label}</div>
</div>
))}
</div>
</div>
</div>
{/* INEN callout */}
<div className="section-gray">
<div className="container" style={{ maxWidth: 720 }}>
<div className="card" style={{ padding: "2rem", borderLeft: "4px solid var(--primary)" }}>
<h3 style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: ".75rem" }}>
📋 Certificaciones INEN ¿Tu producto lo requiere?
</h3>
<p style={{ color: "var(--gray-600)", lineHeight: 1.7, marginBottom: "1rem" }}>
Los productos regulados (calzado, textiles, electrónicos, alimentos, cosméticos) deben cumplir las normas técnicas del <strong>INEN</strong> y obtener el Registro de Conformidad antes del embarque.
Nuestro equipo verifica y gestiona este proceso por ti.
</p>
<div style={{ display: "flex", gap: ".75rem", flexWrap: "wrap" }}>
<Link href="/carga-pesada/inen" className="btn btn-outline btn-sm">Ver productos regulados</Link>
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-sm">Consultar mi caso</Link>
</div>
</div>
</div>
</div>
{/* CTA Final */}
<div className="section" style={{ background: "linear-gradient(135deg, var(--primary) 0%, #0f3a8a 100%)", color: "white" }}>
<div className="container" style={{ textAlign: "center" }}>
<h2 style={{ fontSize: "clamp(1.5rem,3vw,2.25rem)", fontWeight: 800, marginBottom: "1rem" }}>¿Listo para importar?</h2>
<p style={{ color: "rgba(255,255,255,.8)", maxWidth: 520, margin: "0 auto 2rem", fontSize: "1.05rem" }}>
Obtén una cotización sin costo en menos de 48 horas. Nuestro equipo bilingüe NJEcuador te acompaña en todo el proceso.
</p>
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
<Link href="/carga-pesada/cotizacion" className="btn btn-accent btn-xl">Solicitar cotización gratuita </Link>
<Link href="/quienes-somos" className="btn btn-outline btn-xl" style={{ color: "white", borderColor: "rgba(255,255,255,.4)" }}>Conocer el equipo</Link>
</div>
</div>
</div>
</div>
</div>
);
+37 -6
View File
@@ -19,11 +19,15 @@ function PagoContent() {
const router = useRouter();
const params = useSearchParams();
const packageId = params.get("packageId");
const sessionId = params.get("session_id"); // Stripe success callback
const stripeOk = params.get("success") === "1";
const stripeCancelled = params.get("cancelled") === "1";
const user = getUser();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
@@ -36,17 +40,36 @@ function PagoContent() {
.finally(() => setLoading(false));
}, [packageId, router, user]);
// Auto-confirmar cuando Stripe redirige de vuelta con session_id
useEffect(() => {
if (!stripeOk || !sessionId || !data) return;
setConfirming(true);
api.payments.confirmSession(sessionId)
.then(async () => {
setSuccess(true);
const fresh = await api.payments.packageDetail(packageId!);
setData(fresh);
})
.catch(e => setError(e?.message ?? "Error al confirmar el pago."))
.finally(() => setConfirming(false));
}, [stripeOk, sessionId, data]);
const handlePay = async () => {
if (!data) return;
setPaying(true);
setError(null);
try {
// 1. Crear intent de pago
const intent = await api.payments.createIntent(data.package.id);
// 2. En producción aquí se abre Stripe Checkout/PayPhone; en dev confirmamos directamente
// Si Stripe está configurado, redirigir al checkout hospedado
if (intent.checkoutUrl) {
window.location.href = intent.checkoutUrl;
return;
}
// Fallback dev: confirmar directamente (stub sin Stripe)
await api.payments.confirm(intent.id);
setSuccess(true);
// Recargar datos
const fresh = await api.payments.packageDetail(packageId!);
setData(fresh);
} catch (e: any) {
@@ -56,10 +79,11 @@ function PagoContent() {
}
};
if (loading) {
if (loading || confirming) {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "60vh" }}>
<div style={{ display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", minHeight: "60vh", gap: "1rem" }}>
<div className="spinner" />
{confirming && <p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>Verificando pago con Stripe...</p>}
</div>
);
}
@@ -144,6 +168,13 @@ function PagoContent() {
<strong>Valor declarado:</strong> ${pkg?.declaredValue} USD
</div>
{/* Alerta de cancelación de Stripe */}
{stripeCancelled && !success && (
<div style={{ background: "#FFF7ED", border: "1px solid #FED7AA", borderRadius: 8, padding: "1rem", marginBottom: "1.5rem", fontSize: ".875rem", color: "#9A3412" }}>
Cancelaste el proceso de pago. Puedes intentarlo de nuevo cuando quieras.
</div>
)}
{/* Alerta de éxito */}
{(success || alreadyPaid) && (
<div style={{ background: "#F0FDF4", border: "1px solid #86EFAC", borderRadius: 8, padding: "1rem", marginBottom: "1.5rem", display: "flex", gap: ".75rem", alignItems: "flex-start" }}>
@@ -170,7 +201,7 @@ function PagoContent() {
onClick={handlePay}
disabled={paying}
>
{paying ? "Procesando pago..." : `Pagar $${breakdown?.total ?? 0} USD`}
{paying ? "Redirigiendo a Stripe..." : `Pagar $${breakdown?.total ?? 0} USD con Stripe`}
</button>
)}
+244 -55
View File
@@ -1,109 +1,298 @@
"use client";
import { useEffect, useState } from "react";
import { api, getUser, setUser } from "@/lib/api";
import { Timestamp } from "@/app/_components/timestamp";
type Section = "perfil" | "password" | "mfa";
export default function PerfilPage() {
const [user, setUserState] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [mfaSetup, setMfaSetup] = useState<any>(null);
const [totpCode, setTotpCode] = useState("");
const [mfaMsg, setMfaMsg] = useState("");
const [mfaError, setMfaError] = useState("");
const [user, setUserState] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [section, setSection] = useState<Section>("perfil");
// ── Edit profile ──────────────────────────────────────────
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({ firstName: "", lastName: "", phone: "" });
const [editSaving, setEditSaving] = useState(false);
const [editMsg, setEditMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
// ── Change password ───────────────────────────────────────
const [pwForm, setPwForm] = useState({ oldPassword: "", newPassword: "", confirm: "" });
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
// ── MFA ───────────────────────────────────────────────────
const [mfaSetup, setMfaSetup] = useState<any>(null);
const [totpCode, setTotpCode] = useState("");
const [mfaMsg, setMfaMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
useEffect(() => {
api.auth.me().then(me => { setUserState(me); setUser(me); }).catch(() => {}).finally(() => setLoading(false));
api.auth.me()
.then(me => { setUserState(me); setUser(me); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleSetupMfa = async () => {
try {
const data = await api.auth.setupMfa();
setMfaSetup(data);
} catch (err: any) { setMfaError(err.message); }
const reloadUser = async () => {
const me = await api.auth.me();
setUserState(me); setUser(me);
};
// ── Profile edit ──────────────────────────────────────────
const startEdit = () => {
setEditForm({ firstName: user.firstName, lastName: user.lastName, phone: user.phone ?? "" });
setEditMode(true); setEditMsg(null);
};
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault(); setEditMsg(null); setEditSaving(true);
try {
await api.auth.updateProfile(editForm);
await reloadUser();
setEditMode(false);
setEditMsg({ type: "success", text: "Perfil actualizado." });
} catch (err: any) {
setEditMsg({ type: "error", text: err.message ?? "Error al guardar." });
} finally { setEditSaving(false); }
};
// ── Password change ───────────────────────────────────────
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault(); setPwMsg(null);
if (pwForm.newPassword !== pwForm.confirm) {
setPwMsg({ type: "error", text: "Las contraseñas nuevas no coinciden." }); return;
}
if (pwForm.newPassword.length < 8) {
setPwMsg({ type: "error", text: "La nueva contraseña debe tener al menos 8 caracteres." }); return;
}
setPwSaving(true);
try {
await api.auth.changePassword({ oldPassword: pwForm.oldPassword, newPassword: pwForm.newPassword });
setPwMsg({ type: "success", text: "Contraseña cambiada correctamente." });
setPwForm({ oldPassword: "", newPassword: "", confirm: "" });
} catch (err: any) {
setPwMsg({ type: "error", text: err.message ?? "Error al cambiar contraseña." });
} finally { setPwSaving(false); }
};
// ── MFA setup ─────────────────────────────────────────────
const handleSetupMfa = async () => {
try { const data = await api.auth.setupMfa(); setMfaSetup(data); setMfaMsg(null); }
catch (err: any) { setMfaMsg({ type: "error", text: err.message }); }
};
const handleVerifyMfa = async (e: React.FormEvent) => {
e.preventDefault(); setMfaError(""); setMfaMsg("");
e.preventDefault(); setMfaMsg(null);
try {
await api.auth.verifyMfa(totpCode);
setMfaMsg("MFA activado correctamente.");
setMfaMsg({ type: "success", text: "MFA activado correctamente." });
setMfaSetup(null); setTotpCode("");
const me = await api.auth.me();
setUserState(me); setUser(me);
} catch (err: any) { setMfaError(err.message ?? "Código inválido"); }
await reloadUser();
} catch (err: any) { setMfaMsg({ type: "error", text: err.message ?? "Código inválido" }); }
};
const handleDisableMfa = async (e: React.FormEvent) => {
e.preventDefault(); setMfaMsg(null);
try {
await api.auth.disableMfa(totpCode);
setMfaMsg({ type: "success", text: "MFA desactivado." });
setTotpCode("");
await reloadUser();
} catch (err: any) { setMfaMsg({ type: "error", text: err.message ?? "Código inválido" }); }
};
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
if (loading) return <div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}><div className="spinner" /></div>;
const TABS: Array<{ id: Section; label: string; icon: string }> = [
{ id: "perfil", label: "Datos personales", icon: "👤" },
{ id: "password", label: "Contraseña", icon: "🔑" },
{ id: "mfa", label: "Autenticación MFA", icon: "🔐" },
];
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Mi Perfil</h1>
<p className="dash-page-subtitle">Información de tu cuenta.</p>
<p className="dash-page-subtitle">Gestiona tu cuenta y seguridad.</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Datos personales</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
{[
["Nombre", `${user.firstName} ${user.lastName}`],
["Email", user.email],
["Teléfono", user.phone ?? "—"],
["Rol", user.role],
["Casillero", user.suite?.code ?? "—"],
["Miembro desde", new Date(user.createdAt).toLocaleDateString("es-EC")],
["Último acceso", user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString("es-EC") : "—"],
].map(([k, v]) => (
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{k}</span>
<span style={{ fontWeight: 500, fontSize: ".9rem" }}>{v as string}</span>
{/* Tabs */}
<div style={{ display: "flex", gap: ".5rem", marginBottom: "1.5rem", borderBottom: "2px solid var(--gray-100)", paddingBottom: ".5rem", flexWrap: "wrap" }}>
{TABS.map(t => (
<button
key={t.id}
onClick={() => setSection(t.id)}
style={{
padding: ".5rem 1rem",
borderRadius: "var(--radius)",
fontWeight: section === t.id ? 700 : 500,
fontSize: ".875rem",
background: section === t.id ? "var(--primary)" : "transparent",
color: section === t.id ? "white" : "var(--gray-600)",
border: "none",
cursor: "pointer",
transition: "all .15s",
}}
>
{t.icon} {t.label}
</button>
))}
</div>
{/* ── Datos personales ── */}
{section === "perfil" && (
<div className="card" style={{ maxWidth: 560 }}>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 700 }}>Datos personales</span>
{!editMode && (
<button className="btn btn-ghost btn-sm" onClick={startEdit}>Editar</button>
)}
</div>
<div className="card-body">
{editMsg && <div className={`alert alert-${editMsg.type} mb-4`}>{editMsg.text}</div>}
{!editMode ? (
<div style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
{[
["Nombre", `${user.firstName} ${user.lastName}`],
["Email", user.email],
["Teléfono", user.phone ?? "—"],
["Rol", user.role],
["Casillero", user.suite?.code ?? "—"],
["Miembro desde", null],
["Último acceso", null],
].map(([k, v], i) => (
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{k}</span>
<span style={{ fontWeight: 500, fontSize: ".9rem" }}>
{i === 5 ? <Timestamp value={user.createdAt} dateOnly /> :
i === 6 ? (user.lastLoginAt ? <Timestamp value={user.lastLoginAt} /> : "—") :
v as string}
</span>
</div>
))}
</div>
))}
) : (
<form onSubmit={handleSaveProfile} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label className="form-label">Nombre</label>
<input className="form-input" value={editForm.firstName}
onChange={e => setEditForm(f => ({ ...f, firstName: e.target.value }))} required minLength={2} />
</div>
<div>
<label className="form-label">Apellido</label>
<input className="form-input" value={editForm.lastName}
onChange={e => setEditForm(f => ({ ...f, lastName: e.target.value }))} required minLength={2} />
</div>
<div>
<label className="form-label">Teléfono (con código de país)</label>
<input className="form-input" placeholder="+593 99 123 4567" value={editForm.phone}
onChange={e => setEditForm(f => ({ ...f, phone: e.target.value }))} />
<p style={{ fontSize: ".75rem", color: "var(--gray-500)", marginTop: ".25rem" }}>
Incluye código de país para activar notificaciones WhatsApp (ej: +593912345678)
</p>
</div>
<div style={{ display: "flex", gap: ".75rem", justifyContent: "flex-end" }}>
<button type="button" className="btn btn-ghost" onClick={() => setEditMode(false)} disabled={editSaving}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={editSaving}>
{editSaving ? "Guardando..." : "Guardar cambios"}
</button>
</div>
</form>
)}
</div>
</div>
)}
<div className="card">
<div className="card-header"><span className="font-semibold">Seguridad</span></div>
{/* ── Contraseña ── */}
{section === "password" && (
<div className="card" style={{ maxWidth: 460 }}>
<div className="card-header"><span style={{ fontWeight: 700 }}>Cambiar contraseña</span></div>
<div className="card-body">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
{pwMsg && <div className={`alert alert-${pwMsg.type} mb-4`}>{pwMsg.text}</div>}
<form onSubmit={handleChangePassword} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<div style={{ fontWeight: 600 }}>Autenticación en dos pasos (MFA)</div>
<div style={{ fontSize: ".85rem", color: "var(--gray-500)", marginTop: ".2rem" }}>
{user.mfaEnabled ? "Activada — tu cuenta tiene protección extra." : "No activada — te recomendamos habilitarla."}
</div>
<label className="form-label">Contraseña actual</label>
<input type="password" className="form-input" value={pwForm.oldPassword}
onChange={e => setPwForm(f => ({ ...f, oldPassword: e.target.value }))} required autoComplete="current-password" />
</div>
<div>
<label className="form-label">Nueva contraseña</label>
<input type="password" className="form-input" value={pwForm.newPassword}
onChange={e => setPwForm(f => ({ ...f, newPassword: e.target.value }))} required minLength={8} autoComplete="new-password" />
</div>
<div>
<label className="form-label">Confirmar nueva contraseña</label>
<input type="password" className="form-input" value={pwForm.confirm}
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} required minLength={8} autoComplete="new-password" />
</div>
<button type="submit" className="btn btn-primary" disabled={pwSaving}>
{pwSaving ? "Cambiando..." : "Cambiar contraseña"}
</button>
</form>
</div>
</div>
)}
{/* ── MFA ── */}
{section === "mfa" && (
<div className="card" style={{ maxWidth: 520 }}>
<div className="card-header">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 700 }}>Autenticación en dos pasos (TOTP)</span>
<span className={`badge ${user.mfaEnabled ? "badge-green" : "badge-gray"}`}>
{user.mfaEnabled ? "Activada" : "Desactivada"}
</span>
</div>
</div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{mfaMsg && <div className={`alert alert-${mfaMsg.type}`}>{mfaMsg.text}</div>}
{mfaMsg && <div className="alert alert-success mb-4">{mfaMsg}</div>}
{mfaError && <div className="alert alert-error mb-4">{mfaError}</div>}
{/* Activar MFA */}
{!user.mfaEnabled && !mfaSetup && (
<button className="btn btn-outline" onClick={handleSetupMfa}>Activar MFA</button>
<div>
<p style={{ fontSize: ".9rem", color: "var(--gray-600)", marginBottom: "1rem" }}>
La autenticación en dos pasos agrega una capa extra de seguridad. Necesitas una app TOTP como Google Authenticator o Authy.
</p>
<button className="btn btn-primary" onClick={handleSetupMfa}>Activar MFA</button>
</div>
)}
{mfaSetup && (
{!user.mfaEnabled && mfaSetup && (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="alert alert-info">
<strong>1.</strong> Escanea este QR con Google Authenticator, Authy, u otra app TOTP.<br />
<strong>2.</strong> Ingresa el código de 6 dígitos para confirmar.
<strong>Paso 1:</strong> Escanea el QR con tu app TOTP o copia el secreto manualmente.<br />
<strong>Paso 2:</strong> Ingresa el código de 6 dígitos para confirmar.
</div>
<div style={{ background: "var(--gray-100)", borderRadius: "var(--radius)", padding: "1rem", wordBreak: "break-all", fontSize: ".8rem", fontFamily: "monospace" }}>
<div style={{ background: "var(--gray-50)", borderRadius: "var(--radius)", padding: "1rem", fontSize: ".8rem", fontFamily: "monospace", wordBreak: "break-all", color: "var(--gray-700)" }}>
{mfaSetup.otpAuthUrl}
</div>
<p style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>Secret: <code>{mfaSetup.secret}</code></p>
<p style={{ fontSize: ".8rem", color: "var(--gray-500)", margin: 0 }}>
Secreto manual: <code style={{ background: "var(--gray-100)", padding: "2px 6px", borderRadius: 4 }}>{mfaSetup.secret}</code>
</p>
<form onSubmit={handleVerifyMfa} style={{ display: "flex", gap: ".75rem" }}>
<input className="input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
<input className="form-input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
onChange={e => setTotpCode(e.target.value)} maxLength={6} pattern="\d{6}" required />
<button type="submit" className="btn btn-primary">Verificar</button>
<button type="submit" className="btn btn-primary">Verificar y activar</button>
</form>
</div>
)}
{/* Desactivar MFA */}
{user.mfaEnabled && (
<div>
<div className="alert alert-warning" style={{ marginBottom: "1rem" }}>
Para desactivar MFA debes confirmar con un código de tu app TOTP.
</div>
<form onSubmit={handleDisableMfa} style={{ display: "flex", gap: ".75rem" }}>
<input className="form-input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
onChange={e => setTotpCode(e.target.value)} maxLength={6} pattern="\d{6}" required />
<button type="submit" className="btn btn-outline" style={{ borderColor: "var(--error)", color: "var(--error)" }}>
Desactivar MFA
</button>
</form>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
+9 -2
View File
@@ -85,9 +85,14 @@ export const api = {
register: (body: any) => request<any>("/auth/register", { method: "POST", body: JSON.stringify(body) }),
login: (body: any) => request<any>("/auth/login", { method: "POST", body: JSON.stringify(body) }),
me: () => request<any>("/auth/me"),
updateProfile: (body: { firstName?: string; lastName?: string; phone?: string }) =>
request<any>("/auth/me", { method: "PATCH", body: JSON.stringify(body) }),
changePassword: (body: { oldPassword: string; newPassword: string }) =>
request<any>("/auth/password", { method: "PATCH", body: JSON.stringify(body) }),
logout: (refreshToken: string) => request<any>("/auth/logout", { method: "POST", body: JSON.stringify({ refreshToken }) }),
setupMfa: () => request<any>("/auth/mfa/setup", { method: "POST" }),
verifyMfa:(totpCode: string) => request<any>("/auth/mfa/verify", { method: "POST", body: JSON.stringify({ totpCode }) }),
setupMfa: () => request<any>("/auth/mfa/setup", { method: "POST" }),
verifyMfa: (totpCode: string) => request<any>("/auth/mfa/verify", { method: "POST", body: JSON.stringify({ totpCode }) }),
disableMfa: (totpCode: string) => request<any>("/auth/mfa/disable", { method: "POST", body: JSON.stringify({ totpCode }) }),
},
packages: {
list: (params?: Record<string,string>) => request<any[]>("/packages" + (params ? "?" + new URLSearchParams(params) : "")),
@@ -168,6 +173,8 @@ export const api = {
request<any>("/payments/intent", { method: "POST", body: JSON.stringify({ packageId, provider }) }),
confirm: (paymentId: string) =>
request<any>(`/payments/${paymentId}/confirm`, { method: "POST" }),
confirmSession: (sessionId: string) =>
request<any>("/payments/confirm-session", { method: "POST", body: JSON.stringify({ sessionId }) }),
},
consolidations: {
list: () => request<any[]>("/consolidations"),