feat: Fase 1 — Auth JWT+MFA, portales CRUD, UI completa

API:
- AuthModule: register, login, refresh, logout, MFA/TOTP setup+verify
- JwtStrategy + JwtAuthGuard + RolesGuard + CurrentUser decorator
- PackagesModule: CRUD paquetes + historial de estados
- PreAlertsModule: pre-alertas por usuario
- UsersModule: gestión de usuarios + roles + activación
- B2BModule: solicitudes de carga pesada/cotización
- ValidationPipe global + CORS configurado

Web (Next.js 15):
- globals.css completo (design system + utility classes)
- Layout raíz con WhatsApp flotante
- /login + /registro funcionales con JWT y redirección por rol
- /portal: dashboard, mi-casillero, mis-paquetes, pre-alerta, calculadora, perfil
- /admin: dashboard, usuarios (gestión roles/activación), tarifas, reportes, auditoría
- /bodega: dashboard, paquetes (crear+actualizar estado), verificación, despacho
- /tracking: tracking real con progreso visual + historial
- /calculadora: calculadora interactiva real (API SENAE §15)
- /como-funciona, /tarifas, /quienes-somos, /casillero
- /carga-pesada + /carga-pesada/cotizacion (formulario B2B)
- lib/api.ts: cliente HTTP con auto-refresh de token

Roles sincronizados con schema: SUPER_ADMIN, ADMIN_EMPRESA,
OPERADOR_BODEGA, AGENTE_ADUANERO, CLIENTE, SOPORTE
This commit is contained in:
Lizandro Guarnizo
2026-06-01 09:42:38 -05:00
parent 93db76897d
commit 4872053fd1
57 changed files with 4330 additions and 192 deletions
+20
View File
@@ -0,0 +1,20 @@
"use client";
// Auditoría — placeholder con nota de implementación futura (requiere endpoint /audit)
export default function AuditoriaPage() {
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Auditoría</h1><p className="dash-page-subtitle">Registro de acciones del sistema (AuditLog).</p></div>
<div className="card">
<div className="card-body">
<div className="alert alert-info">
El log de auditoría se registra automáticamente en la tabla <code>AuditLog</code> de la base de datos.
Para consultarlo directamente, accede al panel de base de datos o agrega el endpoint <code>GET /api/audit</code> en la API.
</div>
<p style={{ marginTop: "1rem", fontSize: ".9rem", color: "var(--gray-500)" }}>
Acciones registradas: LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, MFA_ENABLED, USER_REGISTER, y más.
</p>
</div>
</div>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/admin", icon: "◈", label: "Dashboard" },
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
{ href: "/bodega", icon: "📦", label: "→ Bodega" },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
if (!["ADMIN_EMPRESA","SUPER_ADMIN"].includes(u.role)) { router.replace("/portal"); return; }
setUser(u);
}, [router]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
clearAuth(); router.push("/login");
};
if (!user) return <div className="loading-overlay"><div className="spinner" /></div>;
return (
<div className="dash-layout">
<aside className="dash-sidebar">
<div className="dash-logo" style={{ color: "var(--accent)" }}>Admin<span style={{ color: "white" }}>Panel</span></div>
<nav className="dash-nav">
{NAV.map(item => (
<Link key={item.href} href={item.href}
className={`dash-nav-item ${pathname.startsWith(item.href) && item.href !== "/admin" ? "active" : pathname === item.href ? "active" : ""}`}>
<span>{item.icon}</span><span>{item.label}</span>
</Link>
))}
</nav>
<div className="dash-user">
<div className="dash-user-name">{user.firstName} {user.lastName}</div>
<div className="dash-user-role" style={{ color: "var(--accent)" }}>{user.role}</div>
<button className="btn btn-ghost btn-sm" style={{ marginTop: ".5rem", color: "rgba(255,255,255,.5)", fontSize: ".8rem" }} onClick={handleLogout}>Cerrar sesión</button>
</div>
</aside>
<div className="dash-main">
<header className="dash-topbar">
<span style={{ fontSize: "1rem", fontWeight: 600 }}>Panel de Administración</span>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{user.email}</span>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function AdminDashboard() {
const [users, setUsers] = useState<any[]>([]);
const [b2b, setB2b] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.users.list(), api.b2b.list()])
.then(([u, b]) => { setUsers(u); setB2b(b); })
.catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
const clientes = users.filter(u => u.role === "CLIENTE").length;
const b2bPending = b2b.filter(r => r.status === "PENDIENTE").length;
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Dashboard Admin</h1></div>
<div className="grid-4" style={{ marginBottom: "2rem" }}>
{[
{ label: "Total usuarios", value: users.length, color: "var(--primary)" },
{ label: "Clientes", value: clientes, color: "var(--green)" },
{ label: "Solicitudes B2B", value: b2bPending, color: "var(--yellow)" },
{ label: "Activos", value: users.filter(u=>u.isActive).length, color: "var(--accent)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Últimos usuarios</span>
<Link href="/admin/usuarios" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
</div>
<div className="table-wrap" style={{ borderRadius: 0, border: "none" }}>
<table>
<thead><tr><th>Nombre</th><th>Email</th><th>Rol</th><th>Estado</th></tr></thead>
<tbody>
{users.slice(0,8).map(u => (
<tr key={u.id}>
<td className="font-medium">{u.firstName} {u.lastName}</td>
<td className="text-sm text-muted">{u.email}</td>
<td><span className="badge badge-blue">{u.role}</span></td>
<td><span className={`badge ${u.isActive ? "badge-green" : "badge-gray"}`}>{u.isActive ? "Activo" : "Inactivo"}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Solicitudes B2B pendientes</span></div>
{b2bPending === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)" }}>Sin solicitudes pendientes.</p>
) : (
b2b.filter(r=>r.status==="PENDIENTE").map(r => (
<div key={r.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600 }}>{r.companyName}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{r.contactEmail}</div>
</div>
))
)}
</div>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function ReportesPage() {
const [users, setUsers] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.users.list(), api.packages.list()])
.then(([u, p]) => { setUsers(u); setPackages(p); })
.catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
const byStatus: Record<string, number> = {};
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
const totalDeclared = packages.reduce((a, p) => a + (p.declaredValueUsd ?? 0), 0);
const byRole: Record<string, number> = {};
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Reportes</h1><p className="dash-page-subtitle">Resumen operativo del sistema.</p></div>
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
{ label: "Entregados", value: byStatus["ENTREGADO"] ?? 0, color: "var(--green)" },
{ label: "En tránsito", value: byStatus["EN_CAMINO_A_ECUADOR"] ?? 0, color: "var(--yellow)" },
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Paquetes por estado</span></div>
<div className="card-body">
{Object.entries(byStatus).map(([status, count]) => (
<div key={status} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ fontSize: ".9rem" }}>{status.replace(/_/g," ")}</span>
<span style={{ fontWeight: 700 }}>{count}</span>
</div>
))}
{Object.keys(byStatus).length === 0 && <p style={{ color: "var(--gray-500)" }}>Sin datos.</p>}
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Usuarios por rol</span></div>
<div className="card-body">
{Object.entries(byRole).map(([role, count]) => (
<div key={role} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ fontSize: ".9rem" }}>{role}</span>
<span style={{ fontWeight: 700 }}>{count}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
// Página de tarifas — muestra tabla estática de precios (§05 doc) + formulario de edición futuro
const TARIFAS = [
{ category: "Mensajería Acelerada", max: "$200", exento: "Sí (≤ $200)", flete: "$8$15/lb", notas: "Hasta 4 kg · sin impuestos" },
{ category: "Courier", max: "$400", exento: "≤ $200", flete: "$8$15/lb", notas: "Impuestos desde $200.01" },
{ category: "Régimen 4×4", max: "$2,000",exento: "No", flete: "Variable", notas: "SENAE arancel + IVA + FODINFA" },
{ category: "Carga pesada (FCL)", max: "Ilimitado", exento: "No", flete: "Cotización", notas: "Tarifa por m³ y peso" },
];
export default function TarifasPage() {
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Tarifas</h1>
<p className="dash-page-subtitle">Tabla de regímenes aduaneros y precios base.</p>
</div>
<div className="card" style={{ marginBottom: "1.5rem" }}>
<div className="card-header"><span className="font-semibold">Regímenes aduaneros (§05 / §15)</span></div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead>
<tr>
<th>Régimen</th><th>Valor máx.</th><th>Exento</th><th>Flete</th><th>Notas</th>
</tr>
</thead>
<tbody>
{TARIFAS.map(t => (
<tr key={t.category}>
<td className="font-semibold">{t.category}</td>
<td>{t.max}</td>
<td><span className={`badge ${t.exento.startsWith("Sí") || t.exento.startsWith("≤") ? "badge-green" : "badge-gray"}`}>{t.exento}</span></td>
<td>{t.flete}</td>
<td className="text-sm text-muted">{t.notas}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="alert alert-info">
<strong>Nota:</strong> Las tarifas de flete varían según el peso volumétrico (L×W×H / 139) vs. peso real. Se cobra el mayor. Los impuestos SENAE (FODINFA 0.5%, Arancel variable, IVA 15%) se calculan automáticamente en la calculadora del portal.
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const ROLES = ["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"];
export default function UsuariosPage() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [updating, setUpdating] = useState<string | null>(null);
const load = (s?: string) => { setLoading(true); api.users.list(s).then(setUsers).catch(()=>{}).finally(()=>setLoading(false)); };
useEffect(() => { load(); }, []);
const handleRoleChange = async (id: string, role: string) => {
setUpdating(id);
try { await api.users.updateRole(id, role); load(); } catch {}
finally { setUpdating(null); }
};
const handleToggleActive = async (id: string, isActive: boolean) => {
setUpdating(id);
try { await api.users.setActive(id, !isActive); load(); } catch {}
finally { setUpdating(null); }
};
return (
<div>
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
<div><h1 className="dash-page-title">Usuarios</h1><p className="dash-page-subtitle">Gestión de cuentas y roles.</p></div>
<div style={{ display: "flex", gap: ".75rem" }}>
<input className="input" style={{ maxWidth: 260 }} placeholder="Buscar por nombre o email…"
value={search} onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === "Enter" && load(search)} />
<button className="btn btn-primary" onClick={() => load(search)}>Buscar</button>
</div>
</div>
<div className="card">
<div className="table-wrap" style={{ borderRadius: "var(--radius-lg)", border: "none" }}>
{loading ? <div style={{ padding: "3rem", textAlign: "center" }}><div className="spinner mx-auto" /></div> : (
<table>
<thead><tr><th>Nombre</th><th>Email</th><th>Casillero</th><th>Rol</th><th>Estado</th><th>Acciones</th></tr></thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td className="font-medium">{u.firstName} {u.lastName}</td>
<td className="text-sm">{u.email}</td>
<td><span className="code-box">{u.suite?.code ?? "—"}</span></td>
<td>
<select className="select" style={{ padding: ".2rem .5rem", fontSize: ".8rem", width: "auto" }}
value={u.role} disabled={updating === u.id}
onChange={e => handleRoleChange(u.id, e.target.value)}>
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</td>
<td><span className={`badge ${u.isActive ? "badge-green" : "badge-red"}`}>{u.isActive ? "Activo" : "Inactivo"}</span></td>
<td>
<button
className={`btn btn-sm ${u.isActive ? "btn-danger" : "btn-success"}`}
disabled={updating === u.id}
onClick={() => handleToggleActive(u.id, u.isActive)}
>
{u.isActive ? "Desactivar" : "Activar"}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// Despacho — paquetes listos para enviar a Ecuador
export default function DespachoPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [msg, setMsg] = useState("");
const load = () => {
api.packages.list({ status: "LISTO_PARA_RETIRO" }).then(setPackages).catch(()=>{}).finally(()=>setLoading(false));
};
useEffect(() => { load(); }, []);
const markDispatched = async (id: string) => {
setUpdating(id);
try {
await api.packages.updateStatus(id, { status: "EN_CAMINO_A_ECUADOR", notes: "Despachado desde bodega NJ" });
setMsg("Paquete marcado como despachado.");
load();
} catch (err: any) { setMsg(err.message ?? "Error"); }
finally { setUpdating(null); }
};
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Despacho</h1><p className="dash-page-subtitle">Paquetes listos para envío a Ecuador.</p></div>
{msg && <div className="alert alert-success mb-4">{msg}</div>}
{loading ? <div className="flex justify-center py-16"><div className="spinner" /></div> : (
packages.length === 0 ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}></div>
<p style={{ color: "var(--gray-500)" }}>No hay paquetes pendientes de despacho.</p>
</div>
) : (
<div className="card">
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
<table>
<thead><tr><th>Tracking</th><th>Cliente</th><th>Peso</th><th>Valor declarado</th><th>Categoría</th><th>Acción</th></tr></thead>
<tbody>
{packages.map(p => (
<tr key={p.id}>
<td className="font-semibold text-primary">{p.trackingId}</td>
<td>{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
<td>{p.weightLb ? `${p.weightLb} lb` : "—"}</td>
<td>{p.declaredValueUsd ? `$${p.declaredValueUsd}` : "—"}</td>
<td><span className="badge badge-blue">{(p.senaeCategory ?? "").replace(/_/g," ")}</span></td>
<td>
<button className="btn btn-primary btn-sm" disabled={updating === p.id}
onClick={() => markDispatched(p.id)}>
{updating === p.id ? "…" : "Despachar →"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/bodega", icon: "◈", label: "Dashboard" },
{ href: "/bodega/paquetes", icon: "📦", label: "Paquetes" },
{ href: "/bodega/verificacion", icon: "✅", label: "Verificación NJ" },
{ href: "/bodega/despacho", icon: "🚢", label: "Despacho" },
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
];
const ALLOWED = ["OPERADOR_BODEGA","AGENTE_ADUANAS","ADMIN","SUPERADMIN"];
export default function BodegaLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
if (!ALLOWED.includes(u.role)) { router.replace("/portal"); return; }
setUser(u);
}, [router]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
clearAuth(); router.push("/login");
};
if (!user) return <div className="loading-overlay"><div className="spinner" /></div>;
return (
<div className="dash-layout">
<aside className="dash-sidebar">
<div className="dash-logo" style={{ color: "var(--green)" }}>Bodega<span style={{ color: "white" }}>NJ</span></div>
<nav className="dash-nav">
{NAV.map(item => (
<Link key={item.href} href={item.href}
className={`dash-nav-item ${pathname === item.href ? "active" : ""}`}>
<span>{item.icon}</span><span>{item.label}</span>
</Link>
))}
</nav>
<div className="dash-user">
<div className="dash-user-name">{user.firstName} {user.lastName}</div>
<div className="dash-user-role" style={{ color: "var(--green)" }}>{user.role}</div>
<button className="btn btn-ghost btn-sm" style={{ marginTop: ".5rem", color: "rgba(255,255,255,.5)", fontSize: ".8rem" }} onClick={handleLogout}>Cerrar sesión</button>
</div>
</aside>
<div className="dash-main">
<header className="dash-topbar">
<span style={{ fontSize: "1rem", fontWeight: 600 }}>Panel de Bodega</span>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{user.email}</span>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function BodegaDashboard() {
const [packages, setPackages] = useState<any[]>([]);
const [preAlerts, setPreAlerts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.packages.list(), api.preAlerts.list()])
.then(([p, a]) => { setPackages(p); setPreAlerts(a); })
.catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
const recibidos = packages.filter(p => p.status === "RECIBIDO_EN_NJ").length;
const enCamino = packages.filter(p => p.status === "EN_CAMINO_A_ECUADOR").length;
const listos = packages.filter(p => p.status === "LISTO_PARA_RETIRO").length;
const alertasPend = preAlerts.filter(p => p.status === "PENDIENTE").length;
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Dashboard Bodega</h1></div>
<div className="grid-4" style={{ marginBottom: "2rem" }}>
{[
{ label: "Recibidos en NJ", value: recibidos, color: "var(--primary)" },
{ label: "En camino a EC", value: enCamino, color: "var(--yellow)" },
{ label: "Listos para retiro",value: listos, color: "var(--green)" },
{ label: "Pre-alertas pend.", value: alertasPend, color: "var(--accent)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Paquetes recientes</span>
<Link href="/bodega/paquetes" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
</div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead><tr><th>Tracking</th><th>Cliente</th><th>Estado</th></tr></thead>
<tbody>
{packages.slice(0,8).map(p => (
<tr key={p.id}>
<td className="font-semibold text-primary">{p.trackingId}</td>
<td className="text-sm">{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
<td><span className="badge badge-blue">{p.status.replace(/_/g," ")}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Pre-alertas pendientes</span></div>
{alertasPend === 0 ? <p style={{ padding: "1.5rem", color: "var(--gray-500)" }}>Sin pre-alertas pendientes.</p> : (
preAlerts.filter(a => a.status === "PENDIENTE").map(a => (
<div key={a.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600 }}>{a.store} {a.orderNumber}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{a.description ?? "Sin descripción"}</div>
</div>
))
)}
</div>
</div>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const STATUSES = ["RECIBIDO_EN_NJ","EN_PROCESO","EN_CAMINO_A_ECUADOR","EN_ADUANA","EN_BODEGA_EC","LISTO_PARA_RETIRO","ENTREGADO","RETENIDO_ADUANA","DEVUELTO","PERDIDO","CANCELADO"];
export default function BodegaPaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<any | null>(null);
const [statusForm, setStatusForm] = useState({ status: "", notes: "", location: "" });
const [updating, setUpdating] = useState(false);
const [msg, setMsg] = useState("");
// Crear paquete
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({ trackingNumber: "", suiteCode: "", description: "", weightLb: "", declaredValueUsd: "" });
const [suites, setSuites] = useState<any[]>([]);
const [creating, setCreating] = useState(false);
const load = () => { setLoading(true); api.packages.list().then(setPackages).catch(()=>{}).finally(()=>setLoading(false)); };
useEffect(() => { load(); }, []);
const handleUpdateStatus = async (e: React.FormEvent) => {
e.preventDefault(); setUpdating(true); setMsg("");
try {
await api.packages.updateStatus(selected.id, { status: statusForm.status, notes: statusForm.notes, location: statusForm.location });
setMsg("Estado actualizado."); load(); setSelected(null);
} catch (err: any) { setMsg(err.message ?? "Error"); }
finally { setUpdating(false); }
};
return (
<div>
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
<div><h1 className="dash-page-title">Paquetes</h1><p className="dash-page-subtitle">Gestión de paquetes en bodega.</p></div>
<button className="btn btn-primary" onClick={() => setShowCreate(!showCreate)}>+ Registrar paquete</button>
</div>
{msg && <div className="alert alert-success mb-4">{msg}</div>}
{/* Modal crear */}
{showCreate && (
<div className="card" style={{ marginBottom: "1.5rem" }}>
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Registrar nuevo paquete</span>
<button className="btn btn-ghost btn-sm" onClick={() => setShowCreate(false)}></button>
</div>
<div className="card-body">
<p className="text-sm text-muted mb-4">Ingresa el tracking del proveedor y el código de suite del cliente para registrar el paquete al recibirlo en NJ.</p>
<form style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: "1rem" }}
onSubmit={async e => {
e.preventDefault(); setCreating(true);
try {
// Buscar suiteId por código
const allPkgs = await api.packages.list();
const suiteMatch = allPkgs.find((p: any) => p.suite?.code === createForm.suiteCode);
// Si no hay coincidencia, mostrar error
if (!suiteMatch && !createForm.suiteCode) { setMsg("Código de suite inválido."); setCreating(false); return; }
await api.packages.create({
trackingNumber: createForm.trackingNumber,
description: createForm.description || undefined,
weightLb: createForm.weightLb ? parseFloat(createForm.weightLb) : undefined,
declaredValueUsd: createForm.declaredValueUsd ? parseFloat(createForm.declaredValueUsd) : undefined,
suiteId: suiteMatch?.suiteId ?? createForm.suiteCode, // API acepta suiteId
});
setMsg("Paquete registrado."); setShowCreate(false); load();
} catch (err: any) { setMsg(err.message); } finally { setCreating(false); }
}}>
<div className="form-group">
<label className="label">Tracking del proveedor *</label>
<input className="input" placeholder="1Z999AA…" value={createForm.trackingNumber}
onChange={e => setCreateForm(f => ({...f, trackingNumber: e.target.value}))} required />
</div>
<div className="form-group">
<label className="label">Suite del cliente (suiteId) *</label>
<input className="input" placeholder="ID del suite" value={createForm.suiteCode}
onChange={e => setCreateForm(f => ({...f, suiteCode: e.target.value}))} required />
</div>
<div className="form-group">
<label className="label">Descripción</label>
<input className="input" placeholder="Producto…" value={createForm.description}
onChange={e => setCreateForm(f => ({...f, description: e.target.value}))} />
</div>
<div className="form-group">
<label className="label">Peso (lb)</label>
<input type="number" className="input" placeholder="2.5" value={createForm.weightLb}
onChange={e => setCreateForm(f => ({...f, weightLb: e.target.value}))} />
</div>
<div className="form-group" style={{ gridColumn: "span 2", textAlign: "right" }}>
<button type="submit" className="btn btn-primary" disabled={creating}>{creating ? "Guardando…" : "Registrar"}</button>
</div>
</form>
</div>
</div>
)}
{/* Tabla */}
<div className="card">
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
{loading ? <div style={{ padding: "3rem", textAlign: "center" }}><div className="spinner mx-auto" /></div> : (
<table>
<thead><tr><th>Tracking</th><th>Proveedor</th><th>Cliente</th><th>Peso</th><th>Estado</th><th>Acción</th></tr></thead>
<tbody>
{packages.map(p => (
<tr key={p.id}>
<td className="font-semibold text-primary">{p.trackingId}</td>
<td className="text-sm">{p.trackingNumber ?? "—"}</td>
<td className="text-sm">{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
<td className="text-sm">{p.weightLb ? `${p.weightLb} lb` : "—"}</td>
<td><span className="badge badge-blue">{p.status.replace(/_/g," ")}</span></td>
<td>
<button className="btn btn-outline btn-sm"
onClick={() => { setSelected(p); setStatusForm({ status: p.status, notes: "", location: "" }); }}>
Actualizar
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Panel actualizar estado */}
{selected && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.5)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}>
<div className="card" style={{ width: "100%", maxWidth: 480 }}>
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Actualizar estado {selected.trackingId}</span>
<button className="btn btn-ghost btn-sm" onClick={() => setSelected(null)}></button>
</div>
<div className="card-body">
<form onSubmit={handleUpdateStatus} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="label">Nuevo estado</label>
<select className="select" value={statusForm.status}
onChange={e => setStatusForm(f => ({...f, status: e.target.value}))}>
{STATUSES.map(s => <option key={s} value={s}>{s.replace(/_/g," ")}</option>)}
</select>
</div>
<div className="form-group">
<label className="label">Ubicación</label>
<input className="input" placeholder="Bodega NJ / Aeropuerto Guayaquil…" value={statusForm.location}
onChange={e => setStatusForm(f => ({...f, location: e.target.value}))} />
</div>
<div className="form-group">
<label className="label">Notas</label>
<textarea className="textarea" style={{ minHeight: 70 }} placeholder="Observaciones…" value={statusForm.notes}
onChange={e => setStatusForm(f => ({...f, notes: e.target.value}))} />
</div>
<button type="submit" className="btn btn-primary" disabled={updating}>{updating ? "Guardando…" : "Guardar estado"}</button>
</form>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// Verificación NJ — muestra pre-alertas pendientes para vincular con paquetes recibidos
export default function VerificacionPage() {
const [alerts, setAlerts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [msg, setMsg] = useState("");
const load = () => { api.preAlerts.list().then(setAlerts).catch(()=>{}).finally(()=>setLoading(false)); };
useEffect(() => { load(); }, []);
const handleStatus = async (id: string, status: string) => {
setUpdating(id);
try { await api.preAlerts.updateStatus(id, { status }); setMsg(`Pre-alerta marcada como ${status}.`); load(); }
catch (err: any) { setMsg(err.message ?? "Error"); }
finally { setUpdating(null); }
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow", RECIBIDO: "badge-blue", VINCULADO: "badge-green", RECHAZADO: "badge-red",
};
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Verificación NJ</h1><p className="dash-page-subtitle">Vincula las pre-alertas de clientes con paquetes recibidos.</p></div>
{msg && <div className="alert alert-success mb-4">{msg}</div>}
{loading ? <div className="flex justify-center py-16"><div className="spinner" /></div> : (
<div className="card">
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
<table>
<thead><tr><th>Tienda</th><th>Orden</th><th>Descripción</th><th>Valor est.</th><th>Tracking proveedor</th><th>Estado</th><th>Acciones</th></tr></thead>
<tbody>
{alerts.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: "center", padding: "2rem", color: "var(--gray-500)" }}>Sin pre-alertas.</td></tr>
) : alerts.map(a => (
<tr key={a.id}>
<td className="font-semibold">{a.store}</td>
<td className="text-sm">{a.orderNumber}</td>
<td className="text-sm">{a.description ?? "—"}</td>
<td className="text-sm">{a.estimatedValueUsd ? `$${a.estimatedValueUsd}` : "—"}</td>
<td className="text-sm">{a.trackingNumber ?? "—"}</td>
<td><span className={`badge ${STATUS_BADGE[a.status] ?? "badge-gray"}`}>{a.status}</span></td>
<td>
<div style={{ display: "flex", gap: ".5rem" }}>
{a.status === "PENDIENTE" && (
<>
<button className="btn btn-success btn-sm" disabled={updating === a.id}
onClick={() => handleStatus(a.id, "RECIBIDO")}>Recibido</button>
<button className="btn btn-danger btn-sm" disabled={updating === a.id}
onClick={() => handleStatus(a.id, "RECHAZADO")}>Rechazar</button>
</>
)}
{a.status === "RECIBIDO" && (
<button className="btn btn-primary btn-sm" disabled={updating === a.id}
onClick={() => handleStatus(a.id, "VINCULADO")}>Vincular</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+134 -73
View File
@@ -1,86 +1,147 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Calculadora de Envíos SENAE — Moraworld Imports",
description: "Calcula el costo total de tu envío desde EE.UU. a Ecuador incluyendo FODINFA, IVA, arancel y flete. Sin registro.",
};
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function CalculadoraPage() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
const [form, setForm] = useState({ weightLb: "", lengthIn: "", widthIn: "", heightIn: "", declaredValueUsd: "", category: "COURIER" });
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleCalc = async (e: React.FormEvent) => {
e.preventDefault(); setLoading(true); setError(""); setResult(null);
try {
const params: Record<string, string> = { category: form.category };
if (form.weightLb) params.weightLb = form.weightLb;
if (form.lengthIn) params.lengthIn = form.lengthIn;
if (form.widthIn) params.widthIn = form.widthIn;
if (form.heightIn) params.heightIn = form.heightIn;
if (form.declaredValueUsd) params.declaredValueUsd = form.declaredValueUsd;
const data = await api.calculator.calculate(params);
setResult(data);
} catch (err: any) { setError(err.message ?? "Error"); }
finally { setLoading(false); }
};
return (
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
<div style={{ maxWidth: 700, margin: "0 auto" }}>
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>
Calculadora SENAE
</h1>
<p style={{ color: "#6B7280" }}>
Sin necesidad de registro. Conoce el costo total antes de comprar.
</p>
</div>
{/* Nota: el formulario interactivo se implementa en Fase 1 con client component */}
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB", marginBottom: 24,
}}>
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
Ingresa los datos de tu paquete
</h2>
<p style={{ color: "#6B7280", fontSize: ".88rem", marginBottom: 20 }}>
El formulario interactivo se habilitará en la Fase 1. Por ahora puedes usar el
endpoint de la API directamente:
</p>
<div style={{
background: "#0D1117", borderRadius: 10, padding: "16px 20px",
fontFamily: "monospace", fontSize: ".8rem", color: "#A5B4FC",
lineHeight: 2,
}}>
<div style={{ color: "rgba(255,255,255,.3)", marginBottom: 8 }}>{`// GET /api/calculator`}</div>
<div>
<span style={{ color: "#6EE7B7" }}>GET</span>{" "}
{apiUrl}/api/calculator?<span style={{ color: "#FCD34D" }}>value</span>=150&
<span style={{ color: "#FCD34D" }}>weight</span>=2&
<span style={{ color: "#FCD34D" }}>category</span>=REGIMEN_4X4
</div>
<div style={{ marginTop: 12, color: "rgba(255,255,255,.3)" }}>
{`// Parámetros: value, weight, category, length, width, height, shipments`}
</div>
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/tracking" className="navbar-link">Tracking</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/login" className="btn btn-outline btn-sm">Ingresar</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
</div>
</nav>
{/* Categorías SENAE informativas */}
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB",
}}>
<h2 style={{ fontWeight: 700, marginBottom: 20, fontSize: "1rem" }}>
Categorías SENAE aplicables
</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
{[
{ name: "Régimen 4×4", rate: "0%", color: "#10B981", bg: "#D1FAE5", desc: "Hasta $400 · 4 kg · 4 envíos/año" },
{ name: "Categoría B", rate: "10%", color: "#1D4ED8", bg: "#DBEAFE", desc: "Bienes generales" },
{ name: "Categoría C", rate: "20%", color: "#FF6B00", bg: "#FFEDD5", desc: "Textiles, calzado, hogar" },
{ name: "Categoría D", rate: "015%", color: "#8B5CF6", bg: "#EDE9FE", desc: "Electrónicos · puede requerir INEN" },
].map((c) => (
<div key={c.name} style={{
background: c.bg, borderRadius: 10, padding: 16, textAlign: "center",
}}>
<div style={{ fontWeight: 800, marginBottom: 4 }}>{c.name}</div>
<div style={{ fontSize: "1.8rem", fontWeight: 900, color: c.color, lineHeight: 1, marginBottom: 6 }}>{c.rate}</div>
<div style={{ fontSize: ".75rem", color: "#4B5563" }}>{c.desc}</div>
</div>
))}
</div>
<div style={{ marginTop: 20, padding: 16, background: "#F3F4F6", borderRadius: 8 }}>
<p style={{ fontSize: ".82rem", color: "#6B7280", lineHeight: 1.6 }}>
<strong>Fórmula:</strong> Flete (Peso × $3.50/lb) + Seguro (2%) + FODINFA (0.5%) + Arancel (% categoría) + IVA (15%)
<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" }}>Calculadora Aduanera</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 480, margin: "0 auto" }}>
Estima los impuestos SENAE según §15 del reglamento vigente.
</p>
</div>
</div>
<div className="container" style={{ maxWidth: 900, margin: "3rem auto", padding: "0 1.5rem" }}>
<div className="grid-2" style={{ gap: "2rem", alignItems: "start" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Datos del envío</span></div>
<div className="card-body">
{error && <div className="alert alert-error mb-4">{error}</div>}
<form onSubmit={handleCalc} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="label">Régimen aduanero</label>
<select className="select" value={form.category} onChange={set("category")}>
<option value="MENSAJERIA_ACELERADA">Mensajería Acelerada ( $200 · sin impuestos)</option>
<option value="COURIER">Courier ( $400 · impuestos desde $200.01)</option>
<option value="REGIMEN_4X4">Régimen 4×4 ( $2,000)</option>
</select>
</div>
<div className="form-group">
<label className="label">Valor declarado en factura (USD)</label>
<input type="number" className="input" placeholder="150.00" min={0} step="0.01" value={form.declaredValueUsd} onChange={set("declaredValueUsd")} />
</div>
<div className="form-group">
<label className="label">Peso real (lb)</label>
<input type="number" className="input" placeholder="2.5" min={0} step="0.1" value={form.weightLb} onChange={set("weightLb")} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: ".75rem" }}>
{[["Largo", "lengthIn"], ["Ancho", "widthIn"], ["Alto", "heightIn"]].map(([l,k]) => (
<div key={k} className="form-group">
<label className="label">{l} (in)</label>
<input type="number" className="input" placeholder="0" min={0} step="0.1"
value={(form as any)[k]} onChange={set(k)} />
</div>
))}
</div>
<button type="submit" className="btn btn-primary btn-lg" style={{ width: "100%" }} disabled={loading}>
{loading ? "Calculando…" : "Calcular impuestos"}
</button>
</form>
</div>
</div>
{result ? (
<div className="card">
<div className="card-header"><span className="font-semibold">Estimación de costos</span></div>
<div className="card-body">
<div style={{ display: "flex", flexDirection: "column", gap: ".6rem" }}>
{[
["Peso a cobrar", `${result.billableWeightLb?.toFixed(2)} lb`],
["Flete estimado", `$${result.fleteUsd?.toFixed(2)}`],
["Seguro", `$${result.seguroUsd?.toFixed(2)}`],
["FODINFA (0.5%)", `$${result.fodinfaUsd?.toFixed(2)}`],
["Arancel", `$${result.arancelUsd?.toFixed(2)}`],
["IVA (15%)", `$${result.ivaUsd?.toFixed(2)}`],
].map(([label, val]) => (
<div key={label as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-700)" }}>{label}</span>
<span style={{ fontWeight: 600 }}>{val}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", padding: "1rem", background: "var(--primary-light)", borderRadius: "var(--radius)", marginTop: ".5rem" }}>
<span style={{ fontWeight: 700, color: "var(--primary)" }}>Total impuestos</span>
<span style={{ fontWeight: 800, fontSize: "1.25rem", color: "var(--primary)" }}>${result.totalTaxesUsd?.toFixed(2)}</span>
</div>
</div>
{result.exento && <div className="alert alert-success mt-4"><strong>Exento</strong> No aplican impuestos SENAE.</div>}
<div className="alert alert-info mt-4">
<strong>Nota:</strong> Esta es una estimación. Los valores finales dependen de la verificación en aduana. Calculado según §15 del Reglamento SENAE.
</div>
<Link href="/registro" className="btn btn-primary" style={{ width: "100%", marginTop: "1rem" }}>
Crear casillero gratis
</Link>
</div>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{[
{ title: "Mensajería Acelerada", desc: "Hasta $200 USD · No paga impuestos · Ideal para compras pequeñas en Amazon.", badge: "Más popular" },
{ title: "Courier", desc: "Hasta $400 USD · Sin impuestos hasta $200 · Proceso rápido.", badge: "" },
{ title: "Régimen 4×4", desc: "Hasta $2,000 USD · Para compras de mayor valor · Aplican impuestos completos.", badge: "" },
].map(r => (
<div key={r.title} className="card" style={{ padding: "1.25rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".5rem" }}>
<span style={{ fontWeight: 700 }}>{r.title}</span>
{r.badge && <span className="badge badge-green">{r.badge}</span>}
</div>
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{r.desc}</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
</main>
</div>
);
}
@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function CotizacionPage() {
const [form, setForm] = useState({
companyName: "", contactName: "", contactEmail: "", contactPhone: "",
description: "", estimatedMonthlyVolume: "",
});
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState("");
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); setLoading(true); setError("");
try {
await api.b2b.create({
...form,
estimatedMonthlyVolume: form.estimatedMonthlyVolume ? parseFloat(form.estimatedMonthlyVolume) : undefined,
});
setSuccess(true);
} catch (err: any) { setError(err.message ?? "Error al enviar solicitud"); }
finally { setLoading(false); }
};
return (
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/carga-pesada" className="navbar-link">Carga Pesada</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</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" }}>Solicitar Cotización B2B</h1>
<p style={{ color: "rgba(255,255,255,.7)" }}>Un asesor te contactará en menos de 24 horas.</p>
</div>
</div>
<div className="container-sm" style={{ margin: "3rem auto", padding: "0 1.5rem" }}>
{success ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}></div>
<h2 style={{ fontWeight: 700, marginBottom: "1rem" }}>¡Solicitud enviada!</h2>
<p style={{ color: "var(--gray-500)", marginBottom: "2rem" }}>
Hemos recibido tu solicitud. Un asesor de Moraworld se comunicará contigo en menos de 24 horas.
</p>
<div style={{ display: "flex", gap: "1rem", justifyContent: "center" }}>
<Link href="/" className="btn btn-outline">Volver al inicio</Link>
<a href="https://wa.me/17327095300" target="_blank" rel="noopener noreferrer" className="btn btn-success">Contactar por WhatsApp</a>
</div>
</div>
) : (
<div className="card">
<div className="card-header"><span className="font-semibold">Formulario de cotización B2B</span></div>
<div className="card-body">
{error && <div className="alert alert-error mb-4">{error}</div>}
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1.125rem" }}>
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="label">Empresa *</label>
<input className="input" placeholder="Mi Empresa S.A." value={form.companyName} onChange={set("companyName")} required />
</div>
<div className="form-group">
<label className="label">Nombre de contacto *</label>
<input className="input" placeholder="Juan Pérez" value={form.contactName} onChange={set("contactName")} required />
</div>
</div>
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="label">Email *</label>
<input type="email" className="input" placeholder="contacto@empresa.com" value={form.contactEmail} onChange={set("contactEmail")} required />
</div>
<div className="form-group">
<label className="label">Teléfono / WhatsApp</label>
<input className="input" placeholder="+593 99 123 4567" value={form.contactPhone} onChange={set("contactPhone")} />
</div>
</div>
<div className="form-group">
<label className="label">Descripción de la solicitud *</label>
<textarea className="textarea" placeholder="Describe el tipo de mercancía, origen, frecuencia de importación, necesidades especiales…" value={form.description} onChange={set("description")} required />
</div>
<div className="form-group">
<label className="label">Volumen mensual estimado (USD)</label>
<input type="number" className="input" placeholder="5000" min={0} value={form.estimatedMonthlyVolume} onChange={set("estimatedMonthlyVolume")} />
</div>
<button type="submit" className="btn btn-primary btn-lg" style={{ width: "100%" }} disabled={loading}>
{loading ? "Enviando…" : "Enviar solicitud"}
</button>
</form>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
export default function CargaPesadaPage() {
return (
<div style={{ minHeight: "100vh" }}>
<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>
</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.
</p>
</div>
</div>
<div className="section">
<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={{ 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>
</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>
</div>
</div>
</div>
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Link from "next/link";
export default function CasilleroPage() {
const address = [
["Nombre", "TU NOMBRE COMPLETO"],
["Dirección", "150 N Day St"],
["Suite", "EC-XXXXX (tu código)"],
["Ciudad", "City of Orange"],
["Estado", "NJ"],
["ZIP", "07050"],
["País", "United States"],
["Teléfono", "+1 (732) 709-5300"],
];
return (
<div style={{ minHeight: "100vh" }}>
<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="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</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" }}>Tu Casillero en EE.UU.</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 560, margin: "0 auto" }}>
Dirección real en New Jersey. Compra en Amazon, eBay, Walmart y más.
</p>
</div>
</div>
<div className="section">
<div className="container-sm">
<div className="grid-2" style={{ gap: "2rem", marginBottom: "3rem" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Formato de dirección</span></div>
<div className="card-body">
<div className="address-box">
{address.map(([k, v]) => (
<div key={k as string} className="address-line">
<span className="address-key">{k}</span>
<span className="address-val" style={{ fontSize: ".85rem" }}>{v as string}</span>
</div>
))}
</div>
<div className="alert alert-info mt-4">
<strong>Importante:</strong> Siempre incluye tu Suite en la dirección. Es tu identificador único en nuestra bodega.
</div>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{[
{ icon: "🆓", title: "Gratis siempre", desc: "Tu casillero no tiene costo mensual ni de mantenimiento." },
{ icon: "⚡", title: "Asignación inmediata", desc: "Al registrarte recibes tu código Suite en segundos." },
{ icon: "📸", title: "Fotos al recibir", desc: "Fotografiamos cada paquete antes de enviarlo a Ecuador." },
{ icon: "🔔", title: "Notificaciones", desc: "Te avisamos por email y WhatsApp en cada paso." },
].map(f => (
<div key={f.title} className="card" style={{ padding: "1.25rem", display: "flex", gap: "1rem" }}>
<span style={{ fontSize: "1.5rem", flexShrink: 0 }}>{f.icon}</span>
<div><strong>{f.title}</strong><br /><span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{f.desc}</span></div>
</div>
))}
</div>
</div>
<div style={{ textAlign: "center" }}>
<Link href="/registro" className="btn btn-primary btn-xl">Crear mi casillero gratis </Link>
</div>
</div>
</div>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
const STEPS = [
{ n: 1, title: "Crea tu casillero gratis", desc: "Regístrate en Moraworld y obtén en segundos tu dirección en New Jersey, EE.UU. (Suite EC-XXXXX)." },
{ n: 2, title: "Compra en tiendas de EE.UU.", desc: "Usa tu dirección en Amazon, eBay, Shein, Nike u otras. La Suite es tu número de identificación." },
{ n: 3, title: "Registra una pre-alerta", desc: "Avísanos qué esperas desde el portal para agilizar el proceso cuando llegue a nuestra bodega." },
{ n: 4, title: "Recibimos en bodega NJ", desc: "Inspeccionamos, pesamos y fotografiamos cada paquete. Te notificamos al instante." },
{ n: 5, title: "Gestión aduanera SENAE", desc: "Aplicamos el régimen correcto (Mensajería Acelerada, Courier o 4×4) para minimizar tus costos." },
{ n: 6, title: "Envío a Ecuador", desc: "Consolidamos tu carga y la enviamos por avión o marítimo. Tracking en tiempo real." },
{ n: 7, title: "Entrega en tu puerta", desc: "Coordinamos la entrega a domicilio en cualquier ciudad de Ecuador." },
];
export default function ComoFuncionaPage() {
return (
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/tracking" className="navbar-link">Tracking</Link>
<Link href="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</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" }}>¿Cómo funciona?</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 560, margin: "0 auto" }}>
En 7 simples pasos, compras en EE.UU. y recibes en Ecuador.
</p>
</div>
</div>
<div className="container" style={{ maxWidth: 720, margin: "4rem auto", padding: "0 1.5rem" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{STEPS.map((s, i) => (
<div key={s.n} style={{ display: "flex", gap: "1.5rem" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
<div style={{ width: 48, height: 48, borderRadius: "9999px", background: "var(--primary)", color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: "1.1rem", flexShrink: 0 }}>{s.n}</div>
{i < STEPS.length - 1 && <div style={{ width: 2, flex: 1, background: "var(--gray-200)", marginTop: ".5rem", minHeight: 40 }} />}
</div>
<div style={{ paddingBottom: "1rem" }}>
<h3 style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: ".5rem" }}>{s.title}</h3>
<p style={{ color: "var(--gray-600)", lineHeight: 1.6 }}>{s.desc}</p>
</div>
</div>
))}
</div>
<div style={{ textAlign: "center", marginTop: "4rem" }}>
<Link href="/registro" className="btn btn-primary btn-xl">Empezar gratis </Link>
</div>
</div>
</div>
</div>
);
}
+362 -15
View File
@@ -1,34 +1,381 @@
:root {
--primary: #0057ff;
--primary-dark: #003fc7;
--primary-light: #e8f0fe;
--accent: #ff6b00;
--accent-dark: #cc5500;
--dark: #0d1117;
--gray-900: #111827;
--gray-700: #374151;
--gray-500: #6b7280;
--gray-300: #d1d5db;
--gray-200: #e5e7eb;
--gray-100: #f3f4f6;
--gray-50: #f9fafb;
--white: #ffffff;
--green: #10b981;
--green-light: #d1fae5;
--red: #ef4444;
--red-light: #fee2e2;
--yellow: #f59e0b;
--yellow-light: #fef3c7;
--blue-light: #dbeafe;
--radius: 8px;
--radius-lg: 16px;
--shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06);
--shadow-md: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.06);
--shadow-lg: 0 10px 15px rgba(0,0,0,.1), 0 4px 6px rgba(0,0,0,.05);
--shadow-xl: 0 20px 25px rgba(0,0,0,.1), 0 10px 10px rgba(0,0,0,.04);
--nav-h: 64px;
--sidebar-w: 240px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { font-family: var(--font-inter), system-ui, sans-serif; color: var(--gray-900); background: var(--white); line-height: 1.6; }
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
button { cursor: pointer; border: none; background: none; font-family: inherit; }
input, textarea, select { font-family: inherit; }
/* ─── Typography ─────────────────────────────────────── */
.text-xs { font-size: .75rem; line-height: 1rem; }
.text-sm { font-size: .875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.text-4xl { font-size: 2.25rem; line-height: 2.5rem; }
.text-5xl { font-size: 3rem; line-height: 1; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-primary { color: var(--primary); }
.text-accent { color: var(--accent); }
.text-muted { color: var(--gray-500); }
.text-white { color: var(--white); }
.text-green { color: var(--green); }
.text-red { color: var(--red); }
.text-yellow { color: var(--yellow); }
/* ─── Layout ─────────────────────────────────────────── */
.container { max-width: 1200px; margin: 0 auto; padding: 0 1.5rem; }
.container-sm { max-width: 720px; margin: 0 auto; padding: 0 1.5rem; }
.container-xs { max-width: 480px; margin: 0 auto; padding: 0 1.5rem; }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1; }
.gap-1 { gap: .25rem; }
.gap-2 { gap: .5rem; }
.gap-3 { gap: .75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-8 { gap: 2rem; }
.grid { display: grid; }
.grid-2 { display: grid; grid-template-columns: repeat(2,1fr); gap: 1.5rem; }
.grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 1.5rem; }
.grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 1.5rem; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.min-h-screen { min-height: 100vh; }
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.inset-0 { inset: 0; }
.z-10 { z-index: 10; }
.z-50 { z-index: 50; }
.z-100 { z-index: 100; }
.overflow-hidden { overflow: hidden; }
.overflow-auto { overflow: auto; }
/* ─── Spacing ────────────────────────────────────────── */
.p-2 { padding: .5rem; }
.p-3 { padding: .75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.p-8 { padding: 2rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-2 { padding-top: .5rem; padding-bottom: .5rem; }
.py-3 { padding-top: .75rem; padding-bottom: .75rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
.py-12 { padding-top: 3rem; padding-bottom: 3rem; }
.py-16 { padding-top: 4rem; padding-bottom: 4rem; }
.py-20 { padding-top: 5rem; padding-bottom: 5rem; }
.mt-1 { margin-top: .25rem; }
.mt-2 { margin-top: .5rem; }
.mt-4 { margin-top: 1rem; }
.mt-6 { margin-top: 1.5rem; }
.mt-8 { margin-top: 2rem; }
.mb-2 { margin-bottom: .5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
/* ─── Colors / Backgrounds ───────────────────────────── */
.bg-white { background: var(--white); }
.bg-gray-50 { background: var(--gray-50); }
.bg-gray-100 { background: var(--gray-100); }
.bg-dark { background: var(--dark); }
.bg-primary { background: var(--primary); }
.bg-primary-light { background: var(--primary-light); }
.bg-accent { background: var(--accent); }
.bg-green { background: var(--green); }
.bg-green-light { background: var(--green-light); }
.bg-red-light { background: var(--red-light); }
.bg-yellow-light { background: var(--yellow-light); }
.bg-blue-light { background: var(--blue-light); }
/* ─── Borders / Radius ───────────────────────────────── */
.rounded { border-radius: var(--radius); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-full { border-radius: 9999px; }
.border { border: 1px solid var(--gray-200); }
.border-2 { border: 2px solid var(--gray-200); }
.border-primary { border-color: var(--primary); }
/* ─── Shadows ────────────────────────────────────────── */
.shadow { box-shadow: var(--shadow); }
.shadow-md { box-shadow: var(--shadow-md); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.shadow-xl { box-shadow: var(--shadow-xl); }
/* ─── Buttons ────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: .5rem; padding: .625rem 1.25rem; border-radius: var(--radius);
font-size: .9rem; font-weight: 600; line-height: 1; transition: all .2s;
cursor: pointer; white-space: nowrap; border: 2px solid transparent;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: var(--white); }
.btn-primary:hover:not(:disabled) { background: var(--primary-dark); }
.btn-accent { background: var(--accent); color: var(--white); }
.btn-accent:hover:not(:disabled) { background: var(--accent-dark); }
.btn-outline { background: transparent; border-color: var(--primary); color: var(--primary); }
.btn-outline:hover:not(:disabled) { background: var(--primary); color: var(--white); }
.btn-ghost { background: transparent; color: var(--gray-700); }
.btn-ghost:hover:not(:disabled) { background: var(--gray-100); }
.btn-danger { background: var(--red); color: var(--white); }
.btn-danger:hover:not(:disabled) { background: #dc2626; }
.btn-success { background: var(--green); color: var(--white); }
.btn-sm { padding: .375rem .875rem; font-size: .8125rem; }
.btn-lg { padding: .875rem 2rem; font-size: 1rem; }
.btn-xl { padding: 1rem 2.5rem; font-size: 1.1rem; }
/* ─── Forms ──────────────────────────────────────────── */
.form-group { display: flex; flex-direction: column; gap: .375rem; }
.label { font-size: .875rem; font-weight: 500; color: var(--gray-700); }
.input {
width: 100%; padding: .625rem .875rem; border-radius: var(--radius);
border: 1.5px solid var(--gray-300); font-size: .9375rem; transition: border-color .2s;
background: var(--white); color: var(--gray-900);
}
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(0,87,255,.1); }
.input.error { border-color: var(--red); }
.select {
width: 100%; padding: .625rem .875rem; border-radius: var(--radius);
border: 1.5px solid var(--gray-300); font-size: .9375rem; background: var(--white);
color: var(--gray-900); cursor: pointer; transition: border-color .2s;
}
.select:focus { outline: none; border-color: var(--primary); }
.textarea {
width: 100%; padding: .625rem .875rem; border-radius: var(--radius);
border: 1.5px solid var(--gray-300); font-size: .9375rem; resize: vertical;
min-height: 100px; transition: border-color .2s;
}
.textarea:focus { outline: none; border-color: var(--primary); }
.form-error { font-size: .8125rem; color: var(--red); margin-top: .25rem; }
.form-hint { font-size: .8125rem; color: var(--gray-500); }
/* ─── Cards ──────────────────────────────────────────── */
.card {
background: var(--white); border-radius: var(--radius-lg);
border: 1px solid var(--gray-200); box-shadow: var(--shadow);
}
.card-header { padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--gray-200); }
.card-body { padding: 1.5rem; }
.card-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--gray-200); background: var(--gray-50); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
/* ─── Alerts / Badges ────────────────────────────────── */
.alert { padding: .875rem 1rem; border-radius: var(--radius); font-size: .9rem; }
.alert-success { background: var(--green-light); color: #065f46; border: 1px solid #6ee7b7; }
.alert-error { background: var(--red-light); color: #991b1b; border: 1px solid #fca5a5; }
.alert-warning { background: var(--yellow-light); color: #92400e; border: 1px solid #fcd34d; }
.alert-info { background: var(--blue-light); color: #1e40af; border: 1px solid #93c5fd; }
.badge {
display: inline-flex; align-items: center; gap: .25rem;
padding: .2rem .6rem; border-radius: 9999px;
font-size: .75rem; font-weight: 600; white-space: nowrap;
}
.badge-blue { background: var(--blue-light); color: #1e40af; }
.badge-green { background: var(--green-light); color: #065f46; }
.badge-yellow { background: var(--yellow-light); color: #92400e; }
.badge-red { background: var(--red-light); color: #991b1b; }
.badge-gray { background: var(--gray-100); color: var(--gray-700); }
.badge-orange { background: #ffedd5; color: #9a3412; }
/* ─── Tables ─────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--gray-200); }
table { width: 100%; border-collapse: collapse; font-size: .875rem; }
thead { background: var(--gray-50); }
th { padding: .75rem 1rem; text-align: left; font-weight: 600; color: var(--gray-700); white-space: nowrap; border-bottom: 1px solid var(--gray-200); }
td { padding: .75rem 1rem; border-bottom: 1px solid var(--gray-100); vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--gray-50); }
/* ─── Navbar ─────────────────────────────────────────── */
.navbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 50;
height: var(--nav-h); background: rgba(255,255,255,.95);
backdrop-filter: blur(8px); border-bottom: 1px solid var(--gray-200);
display: flex; align-items: center;
}
.navbar-inner { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.navbar-logo { font-size: 1.25rem; font-weight: 800; color: var(--primary); }
.navbar-logo span { color: var(--accent); }
.navbar-links { display: flex; align-items: center; gap: 1.5rem; }
.navbar-link { font-size: .9rem; color: var(--gray-700); font-weight: 500; transition: color .2s; }
.navbar-link:hover { color: var(--primary); }
/* ─── Dashboard Sidebar ──────────────────────────────── */
.dash-layout { display: flex; min-height: 100vh; }
.dash-sidebar {
width: var(--sidebar-w); background: var(--dark); color: var(--white);
position: fixed; top: 0; left: 0; bottom: 0; z-index: 40;
display: flex; flex-direction: column; overflow-y: auto;
}
.dash-logo { padding: 1.5rem; font-size: 1.1rem; font-weight: 800; color: var(--white); border-bottom: 1px solid rgba(255,255,255,.1); }
.dash-logo span { color: var(--accent); }
.dash-nav { padding: 1rem 0; flex: 1; }
.dash-nav-item {
display: flex; align-items: center; gap: .75rem;
padding: .625rem 1.5rem; font-size: .875rem; color: rgba(255,255,255,.7);
transition: all .2s; cursor: pointer; border-left: 3px solid transparent;
}
.dash-nav-item:hover { background: rgba(255,255,255,.07); color: var(--white); }
.dash-nav-item.active { background: rgba(0,87,255,.2); color: var(--white); border-left-color: var(--primary); }
.dash-nav-section { padding: .5rem 1.5rem .25rem; font-size: .7rem; font-weight: 700; text-transform: uppercase; color: rgba(255,255,255,.35); letter-spacing: .05em; margin-top: .5rem; }
.dash-user { padding: 1rem 1.5rem; border-top: 1px solid rgba(255,255,255,.1); }
.dash-user-name { font-size: .875rem; font-weight: 600; }
.dash-user-role { font-size: .75rem; color: rgba(255,255,255,.5); }
.dash-main { margin-left: var(--sidebar-w); flex: 1; display: flex; flex-direction: column; }
.dash-topbar { height: var(--nav-h); background: var(--white); border-bottom: 1px solid var(--gray-200); display: flex; align-items: center; justify-content: space-between; padding: 0 2rem; position: sticky; top: 0; z-index: 30; }
.dash-content { padding: 2rem; flex: 1; background: var(--gray-50); }
.dash-page-title { font-size: 1.5rem; font-weight: 700; color: var(--gray-900); }
.dash-page-subtitle { font-size: .9rem; color: var(--gray-500); margin-top: .25rem; }
/* ─── Stats Cards ────────────────────────────────────── */
.stat-card {
background: var(--white); border-radius: var(--radius-lg);
padding: 1.5rem; border: 1px solid var(--gray-200); box-shadow: var(--shadow);
}
.stat-value { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: .875rem; color: var(--gray-500); margin-top: .25rem; }
.stat-change { font-size: .8rem; margin-top: .5rem; }
/* ─── WhatsApp Float ─────────────────────────────────── */
.whatsapp-float {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 999;
width: 56px; height: 56px; border-radius: 9999px;
background: #25d366; display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 16px rgba(37,211,102,.4); transition: transform .2s, box-shadow .2s;
}
.whatsapp-float:hover { transform: scale(1.1); box-shadow: 0 6px 24px rgba(37,211,102,.5); }
.whatsapp-float svg { width: 28px; height: 28px; fill: white; }
/* ─── Hero ───────────────────────────────────────────── */
.hero {
background: linear-gradient(135deg, var(--dark) 0%, #0f2050 100%);
color: var(--white); padding: 8rem 0 5rem;
}
.hero-badge {
display: inline-flex; align-items: center; gap: .5rem;
background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.2);
padding: .375rem 1rem; border-radius: 9999px; font-size: .8rem; margin-bottom: 1.5rem;
}
.hero-title { font-size: clamp(2rem, 5vw, 3.5rem); font-weight: 800; line-height: 1.15; margin-bottom: 1.25rem; }
.hero-title .accent { color: var(--accent); }
.hero-address {
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.15);
border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; font-family: monospace; font-size: .9rem;
}
html {
scroll-behavior: smooth;
/* ─── Section ────────────────────────────────────────── */
.section { padding: 5rem 0; }
.section-gray { padding: 5rem 0; background: var(--gray-50); }
.section-dark { padding: 5rem 0; background: var(--dark); color: var(--white); }
.section-title { font-size: clamp(1.5rem, 3vw, 2.25rem); font-weight: 700; margin-bottom: .75rem; }
.section-sub { font-size: 1.05rem; color: var(--gray-500); max-width: 560px; margin: 0 auto 3rem; }
/* ─── Steps ──────────────────────────────────────────── */
.step-number {
width: 48px; height: 48px; border-radius: 9999px;
background: var(--primary); color: var(--white);
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; font-weight: 800; flex-shrink: 0;
}
.step-connector { width: 2px; height: 40px; background: var(--gray-200); margin: .5rem auto; }
/* ─── Tracking Timeline ──────────────────────────────── */
.timeline { position: relative; padding-left: 2rem; }
.timeline::before { content: ''; position: absolute; left: .75rem; top: 0; bottom: 0; width: 2px; background: var(--gray-200); }
.timeline-item { position: relative; padding-bottom: 1.25rem; }
.timeline-dot {
position: absolute; left: -1.625rem; top: .25rem;
width: 14px; height: 14px; border-radius: 9999px;
border: 2px solid var(--white); box-shadow: 0 0 0 2px var(--gray-300);
}
.timeline-dot.active { background: var(--green); box-shadow: 0 0 0 3px var(--green-light); }
.timeline-dot.current { background: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); }
.timeline-dot.pending { background: var(--gray-300); }
/* ─── Loader / Spinner ───────────────────────────────── */
.spinner {
width: 40px; height: 40px; border-radius: 9999px;
border: 3px solid var(--gray-200); border-top-color: var(--primary);
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-overlay {
position: fixed; inset: 0; background: rgba(255,255,255,.85); z-index: 9999;
display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 1rem;
}
body {
font-family: var(--font-inter), system-ui, sans-serif;
color: var(--gray-900);
background: var(--white);
line-height: 1.6;
}
/* ─── Misc ───────────────────────────────────────────── */
.divider { height: 1px; background: var(--gray-200); margin: 1.5rem 0; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hidden { display: none; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); }
.pointer { cursor: pointer; }
.no-select { user-select: none; }
.code-box { background: var(--gray-100); border-radius: var(--radius); padding: .25rem .5rem; font-family: monospace; font-size: .875rem; color: var(--primary); }
.address-box { background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; font-size: .9rem; }
.address-box .address-line { display: flex; justify-content: space-between; align-items: center; padding: .35rem 0; border-bottom: 1px solid var(--gray-100); }
.address-box .address-line:last-child { border-bottom: none; }
.address-box .address-key { font-weight: 500; color: var(--gray-700); min-width: 110px; }
.address-box .address-val { font-family: monospace; color: var(--gray-900); }
a {
color: inherit;
text-decoration: none;
/* ─── Responsive ─────────────────────────────────────── */
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
.dash-sidebar { transform: translateX(-100%); transition: transform .3s; }
.dash-sidebar.open { transform: translateX(0); }
.dash-main { margin-left: 0; }
.hide-mobile { display: none; }
}
@media (max-width: 1024px) {
.grid-4 { grid-template-columns: repeat(2,1fr); }
.grid-3 { grid-template-columns: repeat(2,1fr); }
}
+18 -12
View File
@@ -2,25 +2,31 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "Moraworld Imports — Casillero en EE.UU. para Ecuador",
description:
"Compra en Amazon, eBay y más. Tu casillero en New Jersey con envío y gestión aduanera a Ecuador.",
description: "Compra en Amazon, eBay y más. Tu casillero en New Jersey con envío y gestión aduanera a Ecuador.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body className={inter.variable}>{children}</body>
<body className={inter.variable}>
{children}
{/* WhatsApp flotante */}
<a
href="https://wa.me/17327095300?text=Hola%20Moraworld%2C%20necesito%20información"
target="_blank"
rel="noopener noreferrer"
className="whatsapp-float"
aria-label="Contactar por WhatsApp"
>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
</a>
</body>
</html>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api, setToken, setRefresh, setUser } from "@/lib/api";
export default function LoginPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", totpCode: "" });
const [requiresMfa, setRequiresMfa] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const data = await api.auth.login(form);
if (data.requiresMfa) {
setRequiresMfa(true);
setLoading(false);
return;
}
setToken(data.accessToken);
setRefresh(data.refreshToken);
setUser(data.user);
// Redirigir según rol
const role = data.user.role;
if (role === "ADMIN_EMPRESA" || role === "SUPER_ADMIN") router.push("/admin");
else if (role === "OPERADOR_BODEGA" || role === "AGENTE_ADUANERO") router.push("/bodega");
else router.push("/portal");
} catch (err: any) {
setError(err.message ?? "Error al iniciar sesión");
} finally {
setLoading(false);
}
};
return (
<div style={{ minHeight: "100vh", background: "linear-gradient(135deg, #0d1117 0%, #0f2050 100%)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1.5rem" }}>
<div style={{ width: "100%", maxWidth: 420 }}>
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" style={{ fontSize: "1.5rem", fontWeight: 800, color: "white" }}>
Moraworld<span style={{ color: "var(--accent)" }}>.</span>
</Link>
<p style={{ color: "rgba(255,255,255,.6)", marginTop: ".5rem", fontSize: ".9rem" }}>
Inicia sesión en tu cuenta
</p>
</div>
<div className="card" style={{ padding: "2rem" }}>
{error && <div className="alert alert-error mb-6">{error}</div>}
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{!requiresMfa ? (
<>
<div className="form-group">
<label className="label">Correo electrónico</label>
<input
type="email" className="input" placeholder="tu@email.com"
value={form.email} onChange={e => setForm({...form, email: e.target.value})}
required autoFocus
/>
</div>
<div className="form-group">
<label className="label">Contraseña</label>
<input
type="password" className="input" placeholder="••••••••"
value={form.password} onChange={e => setForm({...form, password: e.target.value})}
required
/>
</div>
</>
) : (
<div className="form-group">
<label className="label">Código de autenticación (MFA)</label>
<input
type="text" className="input" placeholder="123456" maxLength={6}
value={form.totpCode} onChange={e => setForm({...form, totpCode: e.target.value})}
required autoFocus pattern="\d{6}"
/>
<p className="form-hint">Ingresa el código de tu app de autenticación.</p>
</div>
)}
<button type="submit" className="btn btn-primary" style={{ width: "100%", padding: ".875rem" }} disabled={loading}>
{loading ? "Ingresando..." : requiresMfa ? "Verificar código" : "Iniciar sesión"}
</button>
</form>
{!requiresMfa && (
<div className="text-center mt-6">
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>
¿No tienes cuenta?{" "}
<Link href="/registro" style={{ color: "var(--primary)", fontWeight: 600 }}>
Regístrate gratis
</Link>
</p>
</div>
)}
{requiresMfa && (
<button className="btn btn-ghost" style={{ width: "100%", marginTop: "1rem", fontSize: ".875rem" }}
onClick={() => { setRequiresMfa(false); setForm({...form, totpCode: ""}); }}>
Volver al login
</button>
)}
</div>
<div className="text-center mt-6">
<Link href="/" style={{ fontSize: ".8rem", color: "rgba(255,255,255,.4)" }}>
Volver al sitio
</Link>
</div>
</div>
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
export default function CalculadoraPortalPage() {
const [form, setForm] = useState({ weightLb: "", lengthIn: "", widthIn: "", heightIn: "", declaredValueUsd: "", category: "COURIER" });
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleCalculate = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true); setError(""); setResult(null);
try {
const params: Record<string, string> = { category: form.category };
if (form.weightLb) params.weightLb = form.weightLb;
if (form.lengthIn) params.lengthIn = form.lengthIn;
if (form.widthIn) params.widthIn = form.widthIn;
if (form.heightIn) params.heightIn = form.heightIn;
if (form.declaredValueUsd) params.declaredValueUsd = form.declaredValueUsd;
const data = await api.calculator.calculate(params);
setResult(data);
} catch (err: any) { setError(err.message ?? "Error en el cálculo"); }
finally { setLoading(false); }
};
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Calculadora Aduanera</h1>
<p className="dash-page-subtitle">Estima los costos SENAE para tu importación (§15 Reglamento).</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Datos del paquete</span></div>
<div className="card-body">
{error && <div className="alert alert-error mb-4">{error}</div>}
<form onSubmit={handleCalculate} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="label">Régimen aduanero</label>
<select className="select" value={form.category} onChange={set("category")}>
<option value="COURIER">Courier (hasta $400)</option>
<option value="MENSAJERIA_ACELERADA">Mensajería Acelerada (hasta $200)</option>
<option value="REGIMEN_4X4">Régimen 4×4 (hasta $2000)</option>
</select>
</div>
<div className="form-group">
<label className="label">Valor declarado (USD)</label>
<input type="number" className="input" placeholder="150.00" min={0} step="0.01" value={form.declaredValueUsd} onChange={set("declaredValueUsd")} />
</div>
<div className="form-group">
<label className="label">Peso real (lb)</label>
<input type="number" className="input" placeholder="2.5" min={0} step="0.1" value={form.weightLb} onChange={set("weightLb")} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: ".75rem" }}>
{[["Largo (in)", "lengthIn"], ["Ancho (in)", "widthIn"], ["Alto (in)", "heightIn"]].map(([label, key]) => (
<div key={key} className="form-group">
<label className="label">{label}</label>
<input type="number" className="input" placeholder="0" min={0} step="0.1"
value={(form as any)[key]} onChange={set(key)} />
</div>
))}
</div>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "Calculando…" : "Calcular costos"}
</button>
</form>
</div>
</div>
{result && (
<div className="card">
<div className="card-header"><span className="font-semibold">Resultado estimado</span></div>
<div className="card-body">
<div style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
{[
["Peso a cobrar (lb)", result.billableWeightLb?.toFixed(2)],
["Flete estimado (USD)", `$${result.fleteUsd?.toFixed(2)}`],
["Seguro (USD)", `$${result.seguroUsd?.toFixed(2)}`],
["FODINFA", `$${result.fodinfaUsd?.toFixed(2)}`],
["Arancel", `$${result.arancelUsd?.toFixed(2)}`],
["IVA", `$${result.ivaUsd?.toFixed(2)}`],
].map(([label, val]) => val !== undefined && (
<div key={label as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-700)" }}>{label}</span>
<span style={{ fontWeight: 600 }}>{val}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", padding: ".75rem 0", background: "var(--primary-light)", borderRadius: "var(--radius)", paddingLeft: "1rem", paddingRight: "1rem", marginTop: ".5rem" }}>
<span style={{ fontWeight: 700, color: "var(--primary)" }}>Total impuestos</span>
<span style={{ fontWeight: 800, fontSize: "1.2rem", color: "var(--primary)" }}>${result.totalTaxesUsd?.toFixed(2)}</span>
</div>
{result.exento && (
<div className="alert alert-success">
<strong>Exento de impuestos</strong> Este paquete califica bajo el monto mínimo SENAE.
</div>
)}
</div>
<p style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: "1rem" }}>
* Estimación basada en §15 SENAE. Los valores finales pueden variar.
</p>
</div>
</div>
)}
{!result && (
<div className="card" style={{ background: "var(--gray-50)" }}>
<div className="card-body">
<div style={{ fontWeight: 600, marginBottom: "1rem" }}>Información aduanera</div>
{[
{ regime: "Mensajería Acelerada", limit: "Hasta $200 USD · Exento de impuestos" },
{ regime: "Courier", limit: "Hasta $400 USD · Aplican impuestos desde $200.01" },
{ regime: "Régimen 4×4", limit: "Hasta $2,000 USD · Con impuestos completos" },
].map(r => (
<div key={r.regime} style={{ padding: ".75rem", background: "white", borderRadius: "var(--radius)", marginBottom: ".75rem", border: "1px solid var(--gray-200)" }}>
<div style={{ fontWeight: 600, fontSize: ".9rem" }}>{r.regime}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".2rem" }}>{r.limit}</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth, getRefresh } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/portal", icon: "◈", label: "Dashboard" },
{ href: "/portal/mi-casillero", icon: "📦", label: "Mi Casillero" },
{ href: "/portal/mis-paquetes", icon: "🚚", label: "Mis Paquetes" },
{ href: "/portal/pre-alerta", icon: "🔔", label: "Pre-Alerta" },
{ href: "/portal/calculadora", icon: "🧮", label: "Calculadora" },
{ href: "/portal/perfil", icon: "👤", label: "Mi Perfil" },
];
export default function PortalLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
const [unread, setUnread] = useState(0);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
setUser(u);
}, [router]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
clearAuth();
router.push("/login");
};
if (!user) return <div className="loading-overlay"><div className="spinner" /></div>;
return (
<div className="dash-layout">
{/* Sidebar */}
<aside className="dash-sidebar">
<div className="dash-logo">Mora<span>world</span></div>
<nav className="dash-nav">
{NAV.map(item => (
<Link key={item.href} href={item.href}
className={`dash-nav-item ${pathname === item.href ? "active" : ""}`}>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
))}
</nav>
<div className="dash-user">
<div className="dash-user-name">{user.firstName} {user.lastName}</div>
<div className="dash-user-role">Cliente</div>
<button className="btn btn-ghost btn-sm" style={{ marginTop: ".5rem", color: "rgba(255,255,255,.5)", fontSize: ".8rem" }} onClick={handleLogout}>
Cerrar sesión
</button>
</div>
</aside>
{/* Main */}
<div className="dash-main">
<header className="dash-topbar">
<span style={{ fontSize: "1rem", fontWeight: 600, color: "var(--gray-900)" }}>Portal del Cliente</span>
<div className="flex items-center gap-4">
<Link href="/portal/notificaciones" style={{ position: "relative", fontSize: "1.2rem" }}>
🔔 {unread > 0 && <span style={{ position: "absolute", top: -6, right: -8, background: "var(--red)", color: "white", borderRadius: "9999px", fontSize: ".65rem", fontWeight: 700, padding: "0 4px", minWidth: 16, textAlign: "center" }}>{unread}</span>}
</Link>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{user.email}</span>
</div>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function MiCasilleroPage() {
const [suite, setSuite] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState<string | null>(null);
useEffect(() => {
api.auth.me().then(me => setSuite(me.suite)).catch(() => {}).finally(() => setLoading(false));
}, []);
const copy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(key);
setTimeout(() => setCopied(null), 2000);
});
};
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
if (!suite) return (
<div className="card" style={{ padding: "2rem", textAlign: "center" }}>
<p style={{ color: "var(--gray-500)" }}>No tienes un casillero asignado. Contacta a soporte.</p>
</div>
);
const address = [
{ key: "Nombre", val: "Tu nombre completo" },
{ key: "Dirección", val: "150 N Day St" },
{ key: "Suite", val: suite.code },
{ key: "Ciudad", val: "City of Orange" },
{ key: "Estado", val: "NJ" },
{ key: "ZIP", val: "07050" },
{ key: "País", val: "United States" },
{ key: "Teléfono", val: "+1 (732) 709-5300" },
];
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Mi Casillero</h1>
<p className="dash-page-subtitle">Usa esta dirección al hacer compras en tiendas de EE.UU.</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
<div className="card">
<div className="card-header">
<span className="font-semibold">Tu dirección de envío en EE.UU.</span>
</div>
<div className="card-body">
<div className="address-box">
{address.map(a => (
<div key={a.key} className="address-line">
<span className="address-key">{a.key}</span>
<span className="address-val">{a.val}</span>
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: ".75rem", padding: ".2rem .5rem" }}
onClick={() => copy(a.val, a.key)}
>
{copied === a.key ? "✓" : "Copiar"}
</button>
</div>
))}
</div>
<div className="alert alert-info mt-4">
<strong>Dirección completa:</strong><br />
<span style={{ fontFamily: "monospace", fontSize: ".9rem" }}>
{suite.address ?? `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`}
</span>
</div>
<button
className="btn btn-outline mt-4"
style={{ width: "100%" }}
onClick={() => copy(`150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, United States`, "full")}
>
{copied === "full" ? "✓ Copiado" : "Copiar dirección completa"}
</button>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
<div className="card" style={{ borderLeft: "4px solid var(--primary)" }}>
<div className="card-body">
<div style={{ fontWeight: 700, fontSize: "1.1rem", color: "var(--primary)" }}>Código de casillero</div>
<div style={{ fontSize: "2rem", fontWeight: 800, letterSpacing: 2, marginTop: ".5rem" }}>{suite.code}</div>
</div>
</div>
<div className="card">
<div className="card-body">
<div style={{ fontWeight: 600, marginBottom: "1rem" }}>Cómo usar tu casillero</div>
{[
"Compra en Amazon, eBay, Shein u otras tiendas.",
"Al pagar, usa la dirección de arriba como dirección de envío.",
"Crea una pre-alerta para que sepamos qué viene.",
"Cuando llegue a NJ lo procesamos y enviamos a Ecuador.",
].map((s, i) => (
<div key={i} style={{ display: "flex", gap: ".75rem", marginBottom: ".75rem", fontSize: ".9rem" }}>
<span style={{ width: 24, height: 24, borderRadius: "9999px", background: "var(--primary)", color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700, fontSize: ".8rem", flexShrink: 0 }}>{i + 1}</span>
<span>{s}</span>
</div>
))}
</div>
</div>
<div className="card" style={{ background: "var(--gray-50)" }}>
<div className="card-body">
<div style={{ fontWeight: 600, marginBottom: ".5rem" }}>¿Tienes dudas?</div>
<p style={{ fontSize: ".875rem", color: "var(--gray-500)", marginBottom: "1rem" }}>Nuestro equipo está disponible por WhatsApp para ayudarte.</p>
<a href="https://wa.me/17327095300" target="_blank" rel="noopener noreferrer"
className="btn btn-success" style={{ width: "100%" }}>
Contactar por WhatsApp
</a>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
const STATUS_LABEL: Record<string, string> = {
RECIBIDO_EN_NJ: "Recibido en NJ", EN_PROCESO: "En proceso",
EN_CAMINO_A_ECUADOR: "En camino a Ecuador", EN_ADUANA: "En aduana",
EN_BODEGA_EC: "En bodega EC", LISTO_PARA_RETIRO: "Listo para retiro",
ENTREGADO: "Entregado", RETENIDO_ADUANA: "Retenido", DEVUELTO: "Devuelto",
PERDIDO: "Perdido", CANCELADO: "Cancelado",
};
const STATUS_BADGE: Record<string, string> = {
RECIBIDO_EN_NJ: "badge-blue", EN_PROCESO: "badge-yellow",
EN_CAMINO_A_ECUADOR: "badge-orange", EN_ADUANA: "badge-yellow",
EN_BODEGA_EC: "badge-blue", LISTO_PARA_RETIRO: "badge-green",
ENTREGADO: "badge-green", RETENIDO_ADUANA: "badge-red",
DEVUELTO: "badge-red", PERDIDO: "badge-red", CANCELADO: "badge-gray",
};
export default function MisPaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState<any | null>(null);
useEffect(() => {
api.packages.list().then(setPackages).catch(() => {}).finally(() => setLoading(false));
}, []);
const filtered = packages.filter(p =>
!filter ||
p.trackingId?.toLowerCase().includes(filter.toLowerCase()) ||
p.trackingNumber?.toLowerCase().includes(filter.toLowerCase()) ||
p.description?.toLowerCase().includes(filter.toLowerCase())
);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
return (
<div>
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
<div>
<h1 className="dash-page-title">Mis Paquetes</h1>
<p className="dash-page-subtitle">Historial completo de tus envíos.</p>
</div>
<input
className="input" style={{ maxWidth: 280 }}
placeholder="Buscar por código o descripción..."
value={filter} onChange={e => setFilter(e.target.value)}
/>
</div>
{filtered.length === 0 ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>📦</div>
<p style={{ color: "var(--gray-500)" }}>{filter ? "No hay paquetes que coincidan." : "Aún no tienes paquetes registrados."}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{filtered.map(p => (
<div key={p.id} className="card pointer" onClick={() => setSelected(p === selected ? null : p)}>
<div className="card-body" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "1rem" }}>
<div>
<div style={{ fontWeight: 700, fontSize: "1rem", color: "var(--primary)" }}>{p.trackingId}</div>
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", marginTop: ".2rem" }}>
{p.description ?? "Sin descripción"} · {p.trackingNumber ?? ""}
</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
{new Date(p.createdAt).toLocaleDateString("es-EC")}
</div>
</div>
<div style={{ textAlign: "right" }}>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>{STATUS_LABEL[p.status] ?? p.status}</span>
{p.weightLb && <div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".4rem" }}>{p.weightLb} lb</div>}
</div>
</div>
{/* Historial expandido */}
{selected?.id === p.id && (
<div className="card-footer">
<div style={{ fontWeight: 600, marginBottom: ".75rem" }}>Historial de estados</div>
{p.statusHistory?.length ? (
<div className="timeline">
{p.statusHistory.map((h: any, i: number) => (
<div key={h.id} className="timeline-item">
<div className={`timeline-dot ${i === 0 ? "current" : "active"}`} />
<div>
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>{STATUS_LABEL[h.status] ?? h.status}</div>
{h.notes && <div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{h.notes}</div>}
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>{new Date(h.createdAt).toLocaleString("es-EC")}</div>
</div>
</div>
))}
</div>
) : <p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>Sin historial.</p>}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api, getUser } from "@/lib/api";
const STATUS_LABEL: Record<string, string> = {
RECIBIDO_EN_NJ: "Recibido en NJ",
EN_PROCESO: "En proceso",
EN_CAMINO_A_ECUADOR: "En camino a Ecuador",
EN_ADUANA: "En aduana",
EN_BODEGA_EC: "En bodega EC",
LISTO_PARA_RETIRO: "Listo para retiro",
ENTREGADO: "Entregado",
RETENIDO_ADUANA: "Retenido en aduana",
DEVUELTO: "Devuelto",
PERDIDO: "Perdido",
CANCELADO: "Cancelado",
};
const STATUS_BADGE: Record<string, string> = {
RECIBIDO_EN_NJ: "badge-blue", EN_PROCESO: "badge-yellow",
EN_CAMINO_A_ECUADOR: "badge-orange", EN_ADUANA: "badge-yellow",
EN_BODEGA_EC: "badge-blue", LISTO_PARA_RETIRO: "badge-green",
ENTREGADO: "badge-green", RETENIDO_ADUANA: "badge-red",
DEVUELTO: "badge-red", PERDIDO: "badge-red", CANCELADO: "badge-gray",
};
export default function PortalDashboard() {
const user = getUser();
const [packages, setPackages] = useState<any[]>([]);
const [notifications, setNotifications] = useState<any[]>([]);
const [suite, setSuite] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.packages.list(),
api.auth.me(),
]).then(([pkgs, me]) => {
setPackages(pkgs.slice(0, 5));
setNotifications([]);
setSuite(me.suite);
}).catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
const active = packages.filter(p => !["ENTREGADO","CANCELADO","DEVUELTO"].includes(p.status)).length;
const delivered = packages.filter(p => p.status === "ENTREGADO").length;
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Bienvenido, {user?.firstName} 👋</h1>
<p className="dash-page-subtitle">Gestiona tus envíos y tu casillero en NJ.</p>
</div>
{/* Stats */}
<div className="grid-4" style={{ marginBottom: "2rem" }}>
{[
{ label: "Paquetes activos", value: active, color: "var(--primary)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "Pre-alertas", value: "—", color: "var(--yellow)" },
{ label: "Mi casillero", value: suite?.code ?? "—", color: "var(--accent)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Últimos paquetes */}
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Paquetes recientes</span>
<Link href="/portal/mis-paquetes" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
</div>
<div className="card-body" style={{ padding: 0 }}>
{packages.length === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)", textAlign: "center" }}>Aún no tienes paquetes.</p>
) : (
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<tbody>
{packages.map(p => (
<tr key={p.id}>
<td><Link href={`/portal/mis-paquetes?id=${p.id}`} style={{ color: "var(--primary)", fontWeight: 600 }}>{p.trackingId}</Link></td>
<td className="text-sm text-muted truncate" style={{ maxWidth: 120 }}>{p.description ?? "—"}</td>
<td><span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>{STATUS_LABEL[p.status] ?? p.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Notificaciones */}
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Notificaciones</span>
<span className="badge badge-red">{notifications.length}</span>
</div>
<div className="card-body" style={{ padding: 0 }}>
{notifications.length === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)", textAlign: "center" }}>Sin notificaciones nuevas.</p>
) : (
<div style={{ display: "flex", flexDirection: "column" }}>
{notifications.map(n => (
<div key={n.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600, fontSize: ".9rem" }}>{n.title}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".2rem" }}>{n.body}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
import { api, getUser, setUser } from "@/lib/api";
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("");
useEffect(() => {
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 handleVerifyMfa = async (e: React.FormEvent) => {
e.preventDefault(); setMfaError(""); setMfaMsg("");
try {
await api.auth.verifyMfa(totpCode);
setMfaMsg("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"); }
};
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
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>
</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>
</div>
))}
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Seguridad</span></div>
<div className="card-body">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
<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>
</div>
<span className={`badge ${user.mfaEnabled ? "badge-green" : "badge-gray"}`}>
{user.mfaEnabled ? "Activada" : "Desactivada"}
</span>
</div>
{mfaMsg && <div className="alert alert-success mb-4">{mfaMsg}</div>}
{mfaError && <div className="alert alert-error mb-4">{mfaError}</div>}
{!user.mfaEnabled && !mfaSetup && (
<button className="btn btn-outline" onClick={handleSetupMfa}>Activar MFA</button>
)}
{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.
</div>
<div style={{ background: "var(--gray-100)", borderRadius: "var(--radius)", padding: "1rem", wordBreak: "break-all", fontSize: ".8rem", fontFamily: "monospace" }}>
{mfaSetup.otpAuthUrl}
</div>
<p style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>Secret: <code>{mfaSetup.secret}</code></p>
<form onSubmit={handleVerifyMfa} style={{ display: "flex", gap: ".75rem" }}>
<input className="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>
</form>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function PreAlertaPage() {
const [alerts, setAlerts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState("");
const [error, setError] = useState("");
const [form, setForm] = useState({ store: "", orderNumber: "", description: "", estimatedValueUsd: "", estimatedWeightLb: "", trackingNumber: "" });
const load = () => { api.preAlerts.list().then(setAlerts).catch(() => {}).finally(() => setLoading(false)); };
useEffect(() => { load(); }, []);
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true); setError(""); setSuccess("");
try {
await api.preAlerts.create({
...form,
estimatedValueUsd: form.estimatedValueUsd ? parseFloat(form.estimatedValueUsd) : undefined,
estimatedWeightLb: form.estimatedWeightLb ? parseFloat(form.estimatedWeightLb) : undefined,
});
setSuccess("Pre-alerta registrada exitosamente.");
setForm({ store: "", orderNumber: "", description: "", estimatedValueUsd: "", estimatedWeightLb: "", trackingNumber: "" });
load();
} catch (err: any) { setError(err.message ?? "Error"); }
finally { setSubmitting(false); }
};
const handleDelete = async (id: string) => {
if (!confirm("¿Eliminar esta pre-alerta?")) return;
await api.preAlerts.delete(id).catch(() => {});
load();
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow", RECIBIDO: "badge-blue", VINCULADO: "badge-green", RECHAZADO: "badge-red",
};
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Pre-Alertas</h1>
<p className="dash-page-subtitle">Avísanos qué paquetes esperas para procesarlos más rápido.</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
{/* Formulario */}
<div className="card">
<div className="card-header"><span className="font-semibold">Nueva Pre-Alerta</span></div>
<div className="card-body">
{success && <div className="alert alert-success mb-4">{success}</div>}
{error && <div className="alert alert-error mb-4">{error}</div>}
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="label">Tienda *</label>
<input className="input" placeholder="Amazon, eBay, Shein…" value={form.store} onChange={set("store")} required />
</div>
<div className="form-group">
<label className="label">Número de orden *</label>
<input className="input" placeholder="123-4567890-1234567" value={form.orderNumber} onChange={set("orderNumber")} required />
</div>
<div className="form-group">
<label className="label">Descripción</label>
<textarea className="textarea" placeholder="Descripción del producto" value={form.description} onChange={set("description")} style={{ minHeight: 70 }} />
</div>
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="label">Valor est. (USD)</label>
<input type="number" className="input" placeholder="50.00" min={0} step="0.01" value={form.estimatedValueUsd} onChange={set("estimatedValueUsd")} />
</div>
<div className="form-group">
<label className="label">Peso est. (lb)</label>
<input type="number" className="input" placeholder="2.5" min={0} step="0.1" value={form.estimatedWeightLb} onChange={set("estimatedWeightLb")} />
</div>
</div>
<div className="form-group">
<label className="label">Tracking del proveedor</label>
<input className="input" placeholder="1Z999AA10123456784" value={form.trackingNumber} onChange={set("trackingNumber")} />
</div>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Enviando…" : "Registrar Pre-Alerta"}
</button>
</form>
</div>
</div>
{/* Lista */}
<div className="card">
<div className="card-header"><span className="font-semibold">Mis Pre-Alertas</span></div>
<div style={{ padding: loading ? "2rem" : 0, textAlign: "center" }}>
{loading ? <div className="spinner mx-auto" /> :
alerts.length === 0 ? (
<p style={{ padding: "2rem", color: "var(--gray-500)" }}>No tienes pre-alertas.</p>
) : (
alerts.map(a => (
<div key={a.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div style={{ fontWeight: 600 }}>{a.store}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>Orden: {a.orderNumber}</div>
{a.description && <div style={{ fontSize: ".8rem", color: "var(--gray-400)" }}>{a.description}</div>}
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".4rem" }}>
<span className={`badge ${STATUS_BADGE[a.status] ?? "badge-gray"}`}>{a.status}</span>
{a.status === "PENDIENTE" && (
<button className="btn btn-ghost btn-sm text-red" style={{ fontSize: ".75rem", color: "var(--red)" }} onClick={() => handleDelete(a.id)}>Eliminar</button>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import Link from "next/link";
export default function QuienesSomosPage() {
return (
<div style={{ minHeight: "100vh" }}>
<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>
</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" }}>Quiénes somos</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 560, margin: "0 auto" }}>Conectamos Ecuador con el mundo.</p>
</div>
</div>
<div className="section">
<div className="container-sm">
<div className="grid-2" style={{ gap: "3rem", alignItems: "center", marginBottom: "4rem" }}>
<div>
<h2 style={{ fontSize: "1.75rem", fontWeight: 700, marginBottom: "1rem" }}>Nuestra misión</h2>
<p style={{ color: "var(--gray-600)", lineHeight: 1.8, marginBottom: "1rem" }}>
Moraworld Imports nació con el objetivo de democratizar el acceso a productos del mercado estadounidense para los ecuatorianos.
Ofrecemos un casillero en New Jersey, logística, gestión aduanera SENAE y entrega a domicilio.
</p>
<p style={{ color: "var(--gray-600)", lineHeight: 1.8 }}>
Operamos bajo las normativas COMEX y SENAE vigentes, garantizando transparencia en cada importación.
</p>
</div>
<div style={{ background: "var(--gray-50)", borderRadius: "var(--radius-lg)", padding: "2rem" }}>
{[["Casillero en NJ", "City of Orange, New Jersey — cerca del puerto de Newark."],
["Entidad legal USA", "Moraworld LLC — registrada en New Jersey."],
["Entidad legal EC", "Moraworld S.A.S. — registrada en Ecuador."],
["Operación", "Equipo bilingüe en NJ y Ecuador."],
].map(([k, v]) => (
<div key={k as string} style={{ display: "flex", gap: "1rem", marginBottom: "1.25rem" }}>
<div style={{ width: 8, height: 8, borderRadius: "9999px", background: "var(--primary)", marginTop: 8, flexShrink: 0 }} />
<div><strong>{k}</strong><br /><span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{v as string}</span></div>
</div>
))}
</div>
</div>
<div style={{ textAlign: "center", padding: "3rem", background: "var(--primary)", borderRadius: "var(--radius-lg)", color: "white" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>¿Tienes una empresa? Soluciones B2B</h2>
<p style={{ opacity: .85, marginBottom: "1.5rem" }}>Gestión de importaciones de volumen, representación comercial y logística empresarial.</p>
<Link href="/carga-pesada/cotizacion" className="btn btn-accent btn-lg">Solicitar cotización B2B</Link>
</div>
</div>
</div>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api, setToken, setRefresh, setUser } from "@/lib/api";
export default function RegistroPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", confirmPassword: "", firstName: "", lastName: "", phone: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (form.password !== form.confirmPassword) { setError("Las contraseñas no coinciden."); return; }
if (form.password.length < 8) { setError("La contraseña debe tener al menos 8 caracteres."); return; }
setLoading(true);
try {
const { confirmPassword, ...body } = form;
const data = await api.auth.register(body);
setToken(data.accessToken);
setRefresh(data.refreshToken);
setUser(data.user);
router.push("/portal/mi-casillero");
} catch (err: any) {
setError(err.message ?? "Error al registrarse");
} finally {
setLoading(false);
}
};
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
return (
<div style={{ minHeight: "100vh", background: "linear-gradient(135deg, #0d1117 0%, #0f2050 100%)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1.5rem" }}>
<div style={{ width: "100%", maxWidth: 480 }}>
<div className="text-center mb-8">
<Link href="/" style={{ fontSize: "1.5rem", fontWeight: 800, color: "white" }}>
Moraworld<span style={{ color: "var(--accent)" }}>.</span>
</Link>
<p style={{ color: "rgba(255,255,255,.6)", marginTop: ".5rem", fontSize: ".9rem" }}>
Crea tu casillero gratis en 1 minuto
</p>
</div>
<div className="card" style={{ padding: "2rem" }}>
{error && <div className="alert alert-error mb-6">{error}</div>}
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1.125rem" }}>
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="label">Nombre</label>
<input type="text" className="input" placeholder="Juan" value={form.firstName} onChange={set("firstName")} required minLength={2} />
</div>
<div className="form-group">
<label className="label">Apellido</label>
<input type="text" className="input" placeholder="Pérez" value={form.lastName} onChange={set("lastName")} required minLength={2} />
</div>
</div>
<div className="form-group">
<label className="label">Correo electrónico</label>
<input type="email" className="input" placeholder="juan@email.com" value={form.email} onChange={set("email")} required />
</div>
<div className="form-group">
<label className="label">Teléfono (opcional)</label>
<input type="tel" className="input" placeholder="+593 99 123 4567" value={form.phone} onChange={set("phone")} />
</div>
<div className="form-group">
<label className="label">Contraseña</label>
<input type="password" className="input" placeholder="Mínimo 8 caracteres" value={form.password} onChange={set("password")} required minLength={8} />
</div>
<div className="form-group">
<label className="label">Confirmar contraseña</label>
<input type="password" className="input" placeholder="Repite tu contraseña" value={form.confirmPassword} onChange={set("confirmPassword")} required />
</div>
<button type="submit" className="btn btn-primary" style={{ width: "100%", padding: ".875rem", marginTop: ".25rem" }} disabled={loading}>
{loading ? "Creando cuenta..." : "Crear mi casillero gratis"}
</button>
</form>
<p style={{ fontSize: ".75rem", color: "var(--gray-500)", textAlign: "center", marginTop: "1rem", lineHeight: 1.5 }}>
Al registrarte aceptas nuestros{" "}
<Link href="/terminos" style={{ color: "var(--primary)" }}>Términos de Servicio</Link>{" "}
y{" "}
<Link href="/privacidad" style={{ color: "var(--primary)" }}>Política de Privacidad</Link>.
</p>
<div className="text-center mt-4">
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>
¿Ya tienes cuenta?{" "}
<Link href="/login" style={{ color: "var(--primary)", fontWeight: 600 }}>Inicia sesión</Link>
</p>
</div>
</div>
<div className="text-center mt-6">
<Link href="/" style={{ fontSize: ".8rem", color: "rgba(255,255,255,.4)" }}> Volver al sitio</Link>
</div>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
const TARIFAS = [
{ regime: "Mensajería Acelerada", limit: "Hasta $200 USD", taxes: "Sin impuestos", flete: "$8$15/lb", ideal: "Compras pequeñas · fast fashion · accesorios", badge: "Más popular" },
{ regime: "Courier", limit: "Hasta $400 USD", taxes: "Sin impuestos hasta $200", flete: "$8$15/lb", ideal: "Electrónica · ropa · zapatos", badge: "" },
{ regime: "Régimen 4×4", limit: "Hasta $2,000 USD", taxes: "FODINFA + Arancel + IVA", flete: "Según peso/volumen", ideal: "Equipos, repuestos, herramientas", badge: "" },
{ regime: "Carga pesada (FCL)", limit: "Sin límite", taxes: "Trámite formal", flete: "Cotización por m³", ideal: "Maquinaria · muebles · vehículos", badge: "" },
];
export default function TarifasPublicaPage() {
return (
<div style={{ minHeight: "100vh" }}>
<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="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</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" }}>Tarifas y Costos</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 560, margin: "0 auto" }}>Precios transparentes, sin sorpresas en aduana.</p>
</div>
</div>
<div className="section-gray">
<div className="container">
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "1.5rem" }}>
{TARIFAS.map(t => (
<div key={t.regime} className="card" style={{ padding: "1.5rem", position: "relative", borderTop: t.badge ? "4px solid var(--primary)" : "4px solid transparent" }}>
{t.badge && <span className="badge badge-blue" style={{ position: "absolute", top: "1rem", right: "1rem" }}>{t.badge}</span>}
<h3 style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: ".75rem" }}>{t.regime}</h3>
<div style={{ display: "flex", flexDirection: "column", gap: ".5rem", fontSize: ".9rem" }}>
<div><span style={{ color: "var(--gray-500)" }}>Límite: </span><strong>{t.limit}</strong></div>
<div><span style={{ color: "var(--gray-500)" }}>Impuestos: </span><strong>{t.taxes}</strong></div>
<div><span style={{ color: "var(--gray-500)" }}>Flete: </span><strong>{t.flete}</strong></div>
</div>
<div style={{ marginTop: "1rem", padding: ".75rem", background: "var(--gray-50)", borderRadius: "var(--radius)", fontSize: ".8rem", color: "var(--gray-500)" }}>
Ideal para: {t.ideal}
</div>
</div>
))}
</div>
<div style={{ textAlign: "center", marginTop: "3rem" }}>
<p style={{ color: "var(--gray-500)", marginBottom: "1rem" }}>¿Quieres saber exactamente cuánto pagarás?</p>
<Link href="/calculadora" className="btn btn-primary btn-lg">Usar la calculadora </Link>
</div>
<div className="alert alert-info" style={{ marginTop: "2rem" }}>
<strong>Impuestos SENAE:</strong> FODINFA 0.5% · Arancel 010% según partida arancelaria · IVA 15%.
Calculados sobre CIF (costo + seguro + flete). Los montos del régimen 4×4 se basan en facturas originales. §15 Reglamento COMEX.
</div>
</div>
</div>
</div>
</div>
);
}
+125 -72
View File
@@ -1,87 +1,140 @@
import type { Metadata } from "next";
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export const metadata: Metadata = {
title: "Rastrear Paquete — Moraworld Imports",
description: "Busca tu paquete con el Tracking ID (formato EC-YYYYMMDD-XXXXXX). Sin necesidad de iniciar sesión.",
const STATUS_LABEL: Record<string, string> = {
RECIBIDO_EN_NJ: "Recibido en bodega NJ", EN_PROCESO: "En proceso",
EN_CAMINO_A_ECUADOR: "En camino a Ecuador", EN_ADUANA: "En aduana Ecuador",
EN_BODEGA_EC: "En bodega Ecuador", LISTO_PARA_RETIRO: "Listo para retiro",
ENTREGADO: "Entregado", RETENIDO_ADUANA: "Retenido en aduana",
DEVUELTO: "Devuelto al origen", PERDIDO: "Reportado como perdido", CANCELADO: "Cancelado",
};
export default function TrackingPage() {
const [query, setQuery] = useState("");
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault(); if (!query.trim()) return;
setLoading(true); setError(""); setResult(null);
try {
const data = await api.tracking.search(query.trim());
setResult(data);
} catch (err: any) { setError("No se encontró ningún paquete con ese código."); }
finally { setLoading(false); }
};
const allStatuses = ["RECIBIDO_EN_NJ","EN_PROCESO","EN_CAMINO_A_ECUADOR","EN_ADUANA","EN_BODEGA_EC","LISTO_PARA_RETIRO","ENTREGADO"];
const currentIdx = result ? allStatuses.indexOf(result.status) : -1;
return (
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
<div style={{ maxWidth: 600, margin: "0 auto" }}>
<div style={{ marginBottom: 32 }}>
<Link href="/" style={{ color: "#6B7280", fontSize: ".88rem" }}> Inicio</Link>
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginTop: 8, marginBottom: 8 }}>
Rastrear paquete
</h1>
<p style={{ color: "#6B7280" }}>
Ingresa tu Tracking ID para ver el estado de tu paquete. No necesitas iniciar sesión.
</p>
</div>
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB", marginBottom: 24,
}}>
{/* Formulario — cliente component en Fase 1 */}
<label style={{ display: "block", fontWeight: 600, marginBottom: 8, fontSize: ".9rem" }}>
Tracking ID
</label>
<div style={{
background: "#F3F4F6", borderRadius: 8, padding: "12px 16px",
fontFamily: "monospace", color: "#9CA3AF", fontSize: ".9rem",
border: "1px solid #E5E7EB", marginBottom: 8,
}}>
EC-20260506-082341
<div style={{ minHeight: "100vh" }}>
{/* Nav */}
<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="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/login" className="btn btn-outline btn-sm">Ingresar</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
<p style={{ color: "#9CA3AF", fontSize: ".78rem", marginBottom: 20 }}>
Formato: <strong style={{ fontFamily: "monospace" }}>EC-YYYYMMDD-XXXXXX</strong>
</p>
<div style={{
background: "#EEF3FF", borderRadius: 8, padding: "12px 16px",
fontSize: ".84rem", color: "#1D4ED8",
}}>
El formulario de búsqueda interactiva se habilita en Fase 1.
Por ahora puedes usar la API: <code style={{ fontFamily: "monospace" }}>GET /api/tracking/:id</code>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
{/* Hero */}
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem" }}>
<div className="container" style={{ maxWidth: 640, margin: "0 auto", textAlign: "center" }}>
<h1 style={{ fontSize: "clamp(1.75rem, 4vw, 2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>
Rastrea tu paquete
</h1>
<p style={{ color: "rgba(255,255,255,.7)", marginBottom: "2rem" }}>
Ingresa tu código de tracking Moraworld (EC-XXXXXXXX-XXXXXX)
</p>
<form onSubmit={handleSearch} style={{ display: "flex", gap: ".75rem" }}>
<input
className="input" style={{ flex: 1, fontSize: "1rem" }}
placeholder="EC-20260506-000001"
value={query} onChange={e => setQuery(e.target.value)}
/>
<button type="submit" className="btn btn-accent btn-lg" disabled={loading}>
{loading ? "…" : "Buscar"}
</button>
</form>
</div>
</div>
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB",
}}>
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
11 estados del ciclo de vida
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{[
{ num: "01", name: "REGISTRADO", color: "#6B7280", desc: "Cliente registró la compra" },
{ num: "02", name: "EN_TRANSITO_BODEGA", color: "#F59E0B", desc: "Despachado hacia NJ" },
{ num: "03", name: "RECIBIDO_BODEGA", color: "#3B82F6", desc: "Bodega NJ confirmó recepción" },
{ num: "04", name: "EN_VERIFICACION", color: "#8B5CF6", desc: "Pesando y fotografiando" },
{ num: "05", name: "VERIFICADO", color: "#10B981", desc: "Peso confirmado" },
{ num: "06", name: "DECLARACION_ADUANERA", color: "#0057FF", desc: "DSI aprobada por SENAE" },
{ num: "07", name: "EN_TRANSITO_ECUADOR", color: "#F97316", desc: "En vuelo a Ecuador" },
{ num: "08", name: "EN_ADUANA_ECUADOR", color: "#EF4444", desc: "Inspección en aduana" },
{ num: "09", name: "LISTO_ENTREGA", color: "#84CC16", desc: "Listo para entrega" },
{ num: "10", name: "ENTREGADO", color: "#10B981", desc: "Entregado al cliente ✓" },
{ num: "11", name: "INCIDENCIA", color: "#EF4444", desc: "Problema reportado ⚠" },
].map((s) => (
<div key={s.num} style={{
display: "flex", gap: 10, alignItems: "center",
padding: "8px 12px", background: "#F9FAFB",
borderRadius: 6, border: "1px solid #E5E7EB",
}}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: s.color, flexShrink: 0 }} />
<span style={{ fontFamily: "monospace", fontSize: ".65rem", color: "#9CA3AF", minWidth: 18 }}>{s.num}</span>
<span style={{ fontFamily: "monospace", fontSize: ".75rem", fontWeight: 700, flex: 1 }}>{s.name}</span>
<span style={{ fontSize: ".72rem", color: "#6B7280" }}>{s.desc}</span>
{/* Resultado */}
<div className="container" style={{ maxWidth: 720, margin: "3rem auto", padding: "0 1.5rem" }}>
{error && <div className="alert alert-error">{error}</div>}
{result && (
<div className="card">
<div className="card-header flex justify-between items-center flex-wrap gap-2">
<div>
<div style={{ fontWeight: 800, fontSize: "1.25rem", color: "var(--primary)" }}>{result.trackingId}</div>
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", marginTop: ".25rem" }}>
{result.description ?? "Paquete"} · {result.trackingNumber ?? ""}
</div>
</div>
<span className="badge badge-green" style={{ fontSize: ".9rem", padding: ".4rem .9rem" }}>
{STATUS_LABEL[result.status] ?? result.status}
</span>
</div>
))}
</div>
<div className="card-body">
{/* Barra de progreso */}
<div style={{ marginBottom: "2rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: ".5rem" }}>
{allStatuses.map((s, i) => (
<div key={s} style={{ flex: 1, textAlign: "center" }}>
<div style={{ width: 20, height: 20, borderRadius: "9999px", margin: "0 auto .35rem", background: i <= currentIdx ? "var(--green)" : "var(--gray-200)", border: i === currentIdx ? "3px solid var(--primary)" : "2px solid transparent", transition: "background .3s" }} />
<div style={{ fontSize: ".6rem", color: i <= currentIdx ? "var(--green)" : "var(--gray-400)", fontWeight: i === currentIdx ? 700 : 400, lineHeight: 1.2 }}>
{s.replace(/_/g,"\n")}
</div>
</div>
))}
</div>
<div style={{ height: 4, background: "var(--gray-200)", borderRadius: 9999, margin: "0 10px" }}>
<div style={{ height: "100%", background: "var(--green)", borderRadius: 9999, width: `${currentIdx >= 0 ? (currentIdx / (allStatuses.length - 1)) * 100 : 0}%`, transition: "width .5s" }} />
</div>
</div>
{/* Historial */}
{result.statusHistory?.length > 0 && (
<>
<div style={{ fontWeight: 600, marginBottom: "1rem" }}>Historial de movimientos</div>
<div className="timeline">
{result.statusHistory.map((h: any, i: number) => (
<div key={h.id ?? i} className="timeline-item">
<div className={`timeline-dot ${i === 0 ? "current" : "active"}`} />
<div>
<div style={{ fontWeight: 600 }}>{STATUS_LABEL[h.status] ?? h.status}</div>
{h.notes && <div style={{ fontSize: ".85rem", color: "var(--gray-500)" }}>{h.notes}</div>}
{h.location && <div style={{ fontSize: ".85rem", color: "var(--gray-500)" }}>📍 {h.location}</div>}
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>{new Date(h.createdAt).toLocaleString("es-EC")}</div>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
)}
{!result && !error && (
<div style={{ textAlign: "center", padding: "2rem", color: "var(--gray-500)" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>📦</div>
<p>Ingresa tu código de tracking para ver el estado de tu paquete.</p>
</div>
)}
</div>
</div>
</main>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api";
// ─── Auth token storage ─────────────────────────────────────────────────────
export const getToken = (): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem("mw_access");
};
export const setToken = (t: string) => localStorage.setItem("mw_access", t);
export const getRefresh = (): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem("mw_refresh");
};
export const setRefresh = (t: string) => localStorage.setItem("mw_refresh", t);
export const clearAuth = () => {
localStorage.removeItem("mw_access");
localStorage.removeItem("mw_refresh");
localStorage.removeItem("mw_user");
};
export const getUser = (): any | null => {
if (typeof window === "undefined") return null;
try { return JSON.parse(localStorage.getItem("mw_user") ?? "null"); } catch { return null; }
};
export const setUser = (u: any) => localStorage.setItem("mw_user", JSON.stringify(u));
// ─── Fetch wrapper ──────────────────────────────────────────────────────────
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(opts.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, { ...opts, headers });
// Token expirado — intentar refresh
if (res.status === 401 && getRefresh()) {
const refreshed = await tryRefresh();
if (refreshed) {
headers["Authorization"] = `Bearer ${getToken()}`;
const retry = await fetch(`${API_BASE}${path}`, { ...opts, headers });
if (!retry.ok) throw await extractError(retry);
return retry.json();
} else {
clearAuth();
window.location.href = "/login";
throw new Error("Session expired");
}
}
if (!res.ok) throw await extractError(res);
if (res.status === 204) return undefined as T;
return res.json();
}
async function extractError(res: Response): Promise<Error> {
try {
const body = await res.json();
return new Error(body.message ?? "Error del servidor");
} catch {
return new Error(`HTTP ${res.status}`);
}
}
async function tryRefresh(): Promise<boolean> {
const token = getRefresh();
if (!token) return false;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: token }),
});
if (!res.ok) return false;
const data = await res.json();
setToken(data.accessToken);
setRefresh(data.refreshToken);
return true;
} catch { return false; }
}
// ─── Auth ───────────────────────────────────────────────────────────────────
export const api = {
auth: {
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"),
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 }) }),
},
packages: {
list: (params?: Record<string,string>) => request<any[]>("/packages" + (params ? "?" + new URLSearchParams(params) : "")),
get: (id: string) => request<any>(`/packages/${id}`),
create: (body: any) => request<any>("/packages", { method: "POST", body: JSON.stringify(body) }),
updateStatus: (id: string, body: any) => request<any>(`/packages/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
},
preAlerts: {
list: () => request<any[]>("/pre-alerts"),
create: (body: any) => request<any>("/pre-alerts", { method: "POST", body: JSON.stringify(body) }),
delete: (id: string) => request<any>(`/pre-alerts/${id}`, { method: "DELETE" }),
updateStatus: (id: string, body: any) => request<any>(`/pre-alerts/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
},
users: {
list: (search?: string) => request<any[]>("/users" + (search ? `?search=${search}` : "")),
get: (id: string) => request<any>(`/users/${id}`),
updateRole: (id: string, role: string) => request<any>(`/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setActive: (id: string, isActive: boolean) => request<any>(`/users/${id}/active`, { method: "PATCH", body: JSON.stringify({ isActive }) }),
},
tracking: {
search: (id: string) => request<any>(`/tracking/${id}`),
},
calculator: {
calculate: (params: Record<string,string>) => request<any>("/calculator?" + new URLSearchParams(params)),
},
b2b: {
create: (body: any) => request<any>("/b2b", { method: "POST", body: JSON.stringify(body) }),
list: () => request<any[]>("/b2b"),
updateStatus: (id: string, body: any) => request<any>(`/b2b/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
},
};