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:
@@ -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[]>([]);
|
||||
|
||||
@@ -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 24–48 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 3–5 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 Ecuador–EE.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 NJ–Ecuador 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user