feat: payments module, notification templates, WA float, route fixes

- Add PaymentsModule: POST /payments/intent, POST /:id/confirm, GET /payments, GET /payments/package/:id, GET /payments/track/:trackingId
- Add Payment model to Prisma schema (PaymentStatus enum, Payment table)
- Add NotificationTemplate model + NotificationsController (GET/PUT /notification-templates, POST /notification-templates/seed)
- Update NotificationsService: DB-backed templates with variable interpolation {{trackingId}} {{firstName}} {{status}} {{suiteCode}}
- Fix /bodega/paquetes: replace 11 wrong status strings with correct §08 enum values
- Fix /admin/reportes: replace EN_CAMINO_A_ECUADOR with correct §08 statuses, rewrite report page with proper KPIs and bar charts
- Fix /portal/mis-paquetes: correct §08 statuses, add 'Pagar envío' button for VERIFICADO/DECLARACION_ADUANERA packages
- Add WhatsApp float component (_components/whatsapp-float.tsx, 2 contacts: NJ ops + Cuenca aduana)
- Add /casillero/calculadora and /casillero/registro redirects (§20)
- Add /portal/pago payment page with cost breakdown (§09/§14/§15)
- Add /admin/notificaciones page: view/edit/toggle templates per event×channel
- Admin nav: add Notificaciones link
- api.ts: add notificationTemplates.* and payments.* client methods
- schema.prisma v0.4: PaymentStatus enum, Payment model, NotificationTemplate model
- db push applied to remote DB (46.202.93.92)
- All builds pass (API nest build + Next.js build)
This commit is contained in:
Lizandro Guarnizo
2026-06-01 17:09:05 -05:00
parent b2b292c50a
commit 5565eef554
20 changed files with 1326 additions and 97 deletions
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
// §07 + §21 — Botón flotante WhatsApp con dos contactos
// Reemplaza los números de teléfono con los reales antes de producción.
const CONTACTS = [
{
label: "Operaciones NJ",
sub: "150 N Day St · New Jersey",
phone: "12015550100", // ← reemplazar con número real
msg: "Hola, tengo una consulta sobre mi paquete en New Jersey.",
},
{
label: "Aduana · Cuenca",
sub: "Moraworld Imports S.A.S.",
phone: "593987654321", // ← reemplazar con número real
msg: "Hola, necesito información sobre trámites aduaneros en Ecuador.",
},
];
export function WhatsAppFloat() {
const [open, setOpen] = useState(false);
return (
<div
style={{
position: "fixed", bottom: "1.5rem", right: "1.5rem",
zIndex: 9999, display: "flex", flexDirection: "column",
alignItems: "flex-end", gap: ".75rem",
}}
>
{/* Opciones desplegables */}
{open && (
<div style={{ display: "flex", flexDirection: "column", gap: ".5rem" }}>
{CONTACTS.map(c => (
<a
key={c.phone}
href={`https://wa.me/${c.phone}?text=${encodeURIComponent(c.msg)}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex", alignItems: "center", gap: ".75rem",
background: "#fff", borderRadius: 12, padding: ".75rem 1rem",
boxShadow: "0 4px 20px rgba(0,0,0,.15)",
textDecoration: "none", color: "#111827",
minWidth: 220, transition: "transform .15s",
}}
onMouseEnter={e => { e.currentTarget.style.transform = "translateX(-4px)"; }}
onMouseLeave={e => { e.currentTarget.style.transform = "translateX(0)"; }}
>
<span style={{ fontSize: "1.4rem", lineHeight: 1 }}>💬</span>
<div>
<div style={{ fontWeight: 700, fontSize: ".88rem" }}>{c.label}</div>
<div style={{ fontSize: ".75rem", color: "#6B7280" }}>{c.sub}</div>
</div>
</a>
))}
</div>
)}
{/* Botón principal */}
<button
onClick={() => setOpen(o => !o)}
aria-label="Contactar por WhatsApp"
style={{
width: 56, height: 56, borderRadius: "50%",
background: "#25D366", border: "none", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
boxShadow: "0 4px 16px rgba(37,211,102,.45)",
transition: "transform .2s, box-shadow .2s",
transform: open ? "rotate(45deg)" : "rotate(0deg)",
}}
onMouseEnter={e => {
e.currentTarget.style.transform = open ? "rotate(45deg) scale(1.1)" : "scale(1.1)";
}}
onMouseLeave={e => {
e.currentTarget.style.transform = open ? "rotate(45deg)" : "scale(1)";
}}
>
{open ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="white" strokeWidth="2.5" strokeLinecap="round" />
</svg>
) : (
<svg width="26" height="26" viewBox="0 0 24 24" fill="white">
<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-.347z" />
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2 22l4.948-1.42A9.956 9.956 0 0012 22c5.523 0 10-4.477 10-10S17.523 2 12 2z" />
</svg>
)}
</button>
</div>
);
}
+8 -7
View File
@@ -6,13 +6,14 @@ 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/configuracion", icon: "⚙️", label: "Configuración" },
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
{ href: "/bodega", icon: "📦", label: "→ Bodega" },
{ href: "/admin", icon: "◈", label: "Dashboard" },
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
{ href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" },
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
{ 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 }) {
@@ -0,0 +1,307 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { api } from "@/lib/api";
// §12 — Plantillas de notificación por evento × canal
// Variables disponibles: {{trackingId}}, {{firstName}}, {{status}}, {{suiteCode}}
const EVENT_LABELS: Record<string, string> = {
REGISTRADO: "Paquete registrado",
EN_TRANSITO_BODEGA: "En tránsito a bodega NJ",
RECIBIDO_BODEGA: "Recibido en bodega NJ",
EN_VERIFICACION: "En verificación",
VERIFICADO: "Verificado",
DECLARACION_ADUANERA: "Declaración aduanera",
EN_TRANSITO_ECUADOR: "En tránsito a Ecuador",
EN_ADUANA_ECUADOR: "En aduana Ecuador",
LISTO_ENTREGA: "Listo para entrega",
ENTREGADO: "Entregado",
INCIDENCIA: "Incidencia reportada",
};
const CHANNEL_ICON: Record<string, string> = {
EMAIL: "📧",
WHATSAPP: "💬",
PUSH: "🔔",
};
const CHANNEL_COLOR: Record<string, string> = {
EMAIL: "#3B82F6",
WHATSAPP: "#25D366",
PUSH: "#8B5CF6",
};
const EVENTS_ORDER = Object.keys(EVENT_LABELS);
const CHANNELS = ["EMAIL", "WHATSAPP", "PUSH"];
type Template = {
id: string;
event: string;
channel: string;
subject: string | null;
body: string;
isActive: boolean;
};
export default function NotificacionesPage() {
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(true);
const [seeding, setSeeding] = useState(false);
const [editing, setEditing] = useState<Template | null>(null);
const [editBody, setEditBody] = useState("");
const [editSubject, setEditSubject] = useState("");
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await api.notificationTemplates.list();
setTemplates(data);
} catch {
setTemplates([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const showToast = (msg: string) => {
setToast(msg);
setTimeout(() => setToast(null), 3000);
};
const handleSeed = async () => {
setSeeding(true);
try {
await api.notificationTemplates.seed();
await load();
showToast("Plantillas por defecto creadas correctamente.");
} catch {
showToast("Error al crear plantillas.");
} finally {
setSeeding(false);
}
};
const openEdit = (tpl: Template) => {
setEditing(tpl);
setEditBody(tpl.body);
setEditSubject(tpl.subject ?? "");
};
const handleSave = async () => {
if (!editing) return;
setSaving(true);
try {
await api.notificationTemplates.update(editing.id, {
body: editBody,
subject: editSubject || undefined,
isActive: editing.isActive,
});
setEditing(null);
await load();
showToast("Plantilla guardada.");
} catch {
showToast("Error al guardar.");
} finally {
setSaving(false);
}
};
const toggleActive = async (tpl: Template) => {
try {
await api.notificationTemplates.update(tpl.id, {
body: tpl.body,
subject: tpl.subject ?? undefined,
isActive: !tpl.isActive,
});
setTemplates(prev => prev.map(t => t.id === tpl.id ? { ...t, isActive: !t.isActive } : t));
} catch {
showToast("Error al actualizar estado.");
}
};
// Agrupa plantillas: { event → { channel → Template } }
const byEvent: Record<string, Record<string, Template>> = {};
templates.forEach(t => {
if (!byEvent[t.event]) byEvent[t.event] = {};
byEvent[t.event][t.channel] = t;
});
const isEmpty = templates.length === 0;
return (
<div>
{/* Toast */}
{toast && (
<div style={{
position: "fixed", top: "1rem", right: "1rem", zIndex: 9999,
background: "var(--green)", color: "#fff", borderRadius: 8,
padding: ".75rem 1.25rem", fontWeight: 600, fontSize: ".9rem",
boxShadow: "0 4px 16px rgba(0,0,0,.15)",
}}>{toast}</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "1.5rem" }}>
<div>
<h1 className="dash-page-title">Plantillas de notificación</h1>
<p className="dash-page-subtitle">
Configura los mensajes enviados a clientes en cada estado del ciclo de vida (§12).
<br />
Variables disponibles: <code>{"{{trackingId}}"}</code> <code>{"{{firstName}}"}</code> <code>{"{{status}}"}</code> <code>{"{{suiteCode}}"}</code>
</p>
</div>
{isEmpty && !loading && (
<button
className="btn btn-primary"
onClick={handleSeed}
disabled={seeding}
style={{ whiteSpace: "nowrap" }}
>
{seeding ? "Creando..." : "Crear plantillas por defecto"}
</button>
)}
</div>
{loading ? (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
) : isEmpty ? (
<div className="card" style={{ textAlign: "center", padding: "3rem" }}>
<div style={{ fontSize: "2.5rem", marginBottom: ".75rem" }}>📋</div>
<p style={{ color: "var(--gray-500)", marginBottom: "1rem" }}>
No hay plantillas configuradas. Haz click en "Crear plantillas por defecto" para empezar.
</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{EVENTS_ORDER.map(event => {
const row = byEvent[event] ?? {};
const hasAny = Object.keys(row).length > 0;
if (!hasAny) return null;
return (
<div key={event} className="card">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 700 }}>{EVENT_LABELS[event] ?? event}</span>
<span style={{ fontSize: ".75rem", color: "var(--gray-500)", fontFamily: "monospace" }}>{event}</span>
</div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
{CHANNELS.map(channel => {
const tpl = row[channel];
if (!tpl) return null;
return (
<div
key={channel}
style={{
border: `1px solid ${tpl.isActive ? "var(--gray-200)" : "var(--gray-100)"}`,
borderRadius: 8, padding: ".875rem 1rem",
opacity: tpl.isActive ? 1 : 0.55,
background: tpl.isActive ? "#fff" : "var(--gray-50)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".5rem" }}>
<span style={{ display: "flex", alignItems: "center", gap: ".5rem", fontWeight: 600, fontSize: ".875rem" }}>
<span style={{ fontSize: "1rem" }}>{CHANNEL_ICON[channel]}</span>
<span style={{ color: CHANNEL_COLOR[channel] }}>{channel}</span>
</span>
<div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
<button
className="btn btn-ghost"
style={{ fontSize: ".75rem", padding: ".25rem .6rem" }}
onClick={() => openEdit(tpl)}
>
Editar
</button>
<label className="toggle-wrap" style={{ margin: 0 }}>
<input
type="checkbox"
checked={tpl.isActive}
onChange={() => toggleActive(tpl)}
style={{ display: "none" }}
/>
<span
className="toggle-track"
onClick={() => toggleActive(tpl)}
style={{ background: tpl.isActive ? "var(--primary)" : "var(--gray-300)", cursor: "pointer" }}
>
<span className="toggle-thumb" style={{ transform: tpl.isActive ? "translateX(20px)" : "translateX(0)" }} />
</span>
</label>
</div>
</div>
{channel === "EMAIL" && tpl.subject && (
<div style={{ fontSize: ".78rem", color: "var(--gray-500)", marginBottom: ".35rem" }}>
<strong>Asunto:</strong> {tpl.subject}
</div>
)}
<div style={{ fontSize: ".82rem", color: "var(--gray-700)", lineHeight: 1.5 }}>
{tpl.body}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
{/* Modal de edición */}
{editing && (
<div className="modal-overlay" onClick={() => setEditing(null)}>
<div className="modal-box" onClick={e => e.stopPropagation()} style={{ maxWidth: 580 }}>
<div className="modal-header">
<span>
{CHANNEL_ICON[editing.channel]} Editar plantilla {" "}
<strong>{EVENT_LABELS[editing.event] ?? editing.event}</strong>{" "}
/ <span style={{ color: CHANNEL_COLOR[editing.channel] }}>{editing.channel}</span>
</span>
<button className="btn btn-ghost" style={{ padding: ".25rem .5rem" }} onClick={() => setEditing(null)}></button>
</div>
<div className="modal-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{editing.channel === "EMAIL" && (
<div>
<label style={{ display: "block", fontSize: ".85rem", fontWeight: 600, marginBottom: ".35rem" }}>
Asunto del correo
</label>
<input
className="form-input"
value={editSubject}
onChange={e => setEditSubject(e.target.value)}
placeholder="Ej: Tu paquete {{trackingId}} fue entregado"
/>
</div>
)}
<div>
<label style={{ display: "block", fontSize: ".85rem", fontWeight: 600, marginBottom: ".35rem" }}>
Cuerpo del mensaje
</label>
<textarea
className="form-input"
rows={6}
value={editBody}
onChange={e => setEditBody(e.target.value)}
placeholder="Hola {{firstName}}, tu paquete {{trackingId}} ..."
style={{ resize: "vertical", fontFamily: "inherit" }}
/>
<p style={{ fontSize: ".75rem", color: "var(--gray-500)", marginTop: ".35rem" }}>
Variables: <code>{"{{trackingId}}"}</code> <code>{"{{firstName}}"}</code> <code>{"{{status}}"}</code> <code>{"{{suiteCode}}"}</code>
</p>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setEditing(null)}>Cancelar</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !editBody.trim()}>
{saving ? "Guardando..." : "Guardar cambios"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+92 -28
View File
@@ -2,66 +2,130 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// §08 — estados oficiales del ciclo de vida
const STATUS_COLORS: Record<string, string> = {
REGISTRADO: "#6B7280",
EN_TRANSITO_BODEGA: "#F59E0B",
RECIBIDO_BODEGA: "#3B82F6",
EN_VERIFICACION: "#8B5CF6",
VERIFICADO: "#10B981",
DECLARACION_ADUANERA: "#0057FF",
EN_TRANSITO_ECUADOR: "#F97316",
EN_ADUANA_ECUADOR: "#EF4444",
LISTO_ENTREGA: "#84CC16",
ENTREGADO: "#10B981",
INCIDENCIA: "#EF4444",
};
export default function ReportesPage() {
const [users, setUsers] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
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));
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
if (loading) return <div style={{ display:"flex", justifyContent:"center", padding:"4rem" }}><div className="spinner" /></div>;
const 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 totalDeclared = packages.reduce((a, p) => a + parseFloat(p.declaredValue ?? "0"), 0);
const inTransit = (byStatus["EN_TRANSITO_BODEGA"] ?? 0) + (byStatus["EN_TRANSITO_ECUADOR"] ?? 0);
const delivered = byStatus["ENTREGADO"] ?? 0;
const incidents = byStatus["INCIDENCIA"] ?? 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 style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Reportes</h1>
<p className="dash-page-subtitle">Resumen operativo del sistema ingresos, volumen y estado de envíos.</p>
</div>
{/* KPIs */}
<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)" },
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "En tránsito", value: inTransit, color: "var(--yellow)" },
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" },
{ label: "Incidencias", value: incidents, color: "var(--red)" },
{ label: "Usuarios", value: users.length, color: "var(--primary)" },
{ label: "Clientes", value: byRole["CLIENTE"] ?? 0, color: "#8B5CF6" },
{ label: "Pendiente aduana", value: byStatus["DECLARACION_ADUANERA"] ?? 0, color: "#0057FF" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-value" style={{ color: s.color, fontSize: "1.75rem" }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Paquetes por estado */}
<div className="card">
<div className="card-header"><span className="font-semibold">Paquetes por estado</span></div>
<div className="card-header"><span className="font-semibold">Paquetes por estado (§08)</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>}
{Object.keys(STATUS_COLORS).map(status => {
const count = byStatus[status] ?? 0;
const pct = packages.length ? Math.round((count / packages.length) * 100) : 0;
return (
<div key={status} style={{ marginBottom: ".75rem" }}>
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:".2rem" }}>
<span style={{ fontSize:".8rem", display:"flex", alignItems:"center", gap:".4rem" }}>
<span style={{ width:8, height:8, borderRadius:"50%", background: STATUS_COLORS[status], display:"inline-block", flexShrink:0 }} />
{status.replace(/_/g," ")}
</span>
<span style={{ fontWeight:700, fontSize:".85rem" }}>{count}</span>
</div>
<div style={{ height:4, background:"var(--gray-100)", borderRadius:999 }}>
<div style={{ height:4, borderRadius:999, background: STATUS_COLORS[status], width:`${pct}%`, transition:"width .4s" }} />
</div>
</div>
);
})}
{packages.length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>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 style={{ display:"flex", flexDirection:"column", gap:"1.5rem" }}>
{/* Usuarios por rol */}
<div className="card">
<div className="card-header"><span className="font-semibold">Usuarios por rol (§06)</span></div>
<div className="card-body">
{Object.entries(byRole).map(([role, count]) => (
<div key={role} style={{ display:"flex", justifyContent:"space-between", padding:".4rem 0", borderBottom:"1px solid var(--gray-100)" }}>
<span style={{ fontSize:".875rem" }}>{role}</span>
<span style={{ fontWeight:700 }}>{count}</span>
</div>
))}
{Object.keys(byRole).length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>Sin datos.</p>}
</div>
</div>
{/* Métricas rápidas */}
<div className="card">
<div className="card-header"><span className="font-semibold">Rendimiento</span></div>
<div className="card-body" style={{ display:"flex", flexDirection:"column", gap:".75rem" }}>
{[
["Tasa de entrega", packages.length ? `${Math.round((delivered/packages.length)*100)}%` : "—"],
["Tasa de incidencias", packages.length ? `${Math.round((incidents/packages.length)*100)}%` : "—"],
["Pendiente declaración", byStatus["VERIFICADO"] ?? 0],
["En bodega NJ", (byStatus["RECIBIDO_BODEGA"] ?? 0) + (byStatus["EN_VERIFICACION"] ?? 0) + (byStatus["VERIFICADO"] ?? 0)],
].map(([k, v]) => (
<div key={k as string} style={{ display:"flex", justifyContent:"space-between", padding:".375rem 0", borderBottom:"1px solid var(--gray-100)" }}>
<span style={{ color:"var(--gray-600)", fontSize:".875rem" }}>{k}</span>
<span style={{ fontWeight:600 }}>{v}</span>
</div>
))}
</div>
</div>
</div>
</div>
+14 -1
View File
@@ -2,7 +2,20 @@
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"];
// §08 — 11 estados oficiales del ciclo de vida del paquete
const STATUSES = [
"REGISTRADO",
"EN_TRANSITO_BODEGA",
"RECIBIDO_BODEGA",
"EN_VERIFICACION",
"VERIFICADO",
"DECLARACION_ADUANERA",
"EN_TRANSITO_ECUADOR",
"EN_ADUANA_ECUADOR",
"LISTO_ENTREGA",
"ENTREGADO",
"INCIDENCIA",
];
export default function BodegaPaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
@@ -0,0 +1,6 @@
// §20 — /casillero/calculadora → redirige a /calculadora
import { redirect } from "next/navigation";
export default function CasilleroCalculadoraPage() {
redirect("/calculadora");
}
@@ -0,0 +1,6 @@
// §20 — /casillero/registro → redirige a /registro
import { redirect } from "next/navigation";
export default function CasilleroRegistroPage() {
redirect("/registro");
}
+93
View File
@@ -367,6 +367,99 @@ tbody tr:hover { background: var(--gray-50); }
.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); }
/* ─── Toggle Switch ──────────────────────────────────── */
.toggle-wrap { display: flex; align-items: center; gap: .5rem; cursor: pointer; user-select: none; }
.toggle-wrap input[type="checkbox"] { display: none; }
.toggle-track {
position: relative; width: 36px; height: 20px; border-radius: 9999px;
background: var(--gray-300); transition: background .2s; flex-shrink: 0;
}
.toggle-thumb {
position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; border-radius: 9999px;
background: var(--white); box-shadow: 0 1px 3px rgba(0,0,0,.3);
transition: transform .2s;
}
.toggle-wrap input:checked ~ .toggle-track { background: var(--primary); }
.toggle-wrap input:checked ~ .toggle-track .toggle-thumb { transform: translateX(16px); }
.toggle-label { font-size: .8rem; font-weight: 500; color: var(--gray-600); }
/* ─── Integration field row ──────────────────────────── */
.int-field {
display: grid; grid-template-columns: 20px 1fr auto auto;
align-items: center; gap: 1rem;
padding: .875rem 1rem; border-bottom: 1px solid var(--gray-100);
}
.int-field:last-child { border-bottom: none; }
.int-field:hover { background: var(--gray-50); border-radius: var(--radius); }
.int-dot { width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0; }
.int-dot.has-value { background: var(--green); box-shadow: 0 0 0 3px var(--green-light); }
.int-dot.no-value { background: var(--gray-300); }
.int-label-wrap { min-width: 0; }
.int-label { font-size: .875rem; font-weight: 500; color: var(--gray-800); display: flex; align-items: center; gap: .375rem; }
.int-hint { font-size: .75rem; color: var(--gray-500); margin-top: .1rem; }
.int-input-wrap { position: relative; width: 260px; flex-shrink: 0; }
.int-input {
width: 100%; padding: .5rem 2.5rem .5rem .75rem;
border: 1.5px solid var(--gray-300); border-radius: var(--radius);
font-size: .875rem; background: var(--white); color: var(--gray-900);
transition: border-color .2s; font-family: monospace;
}
.int-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(0,87,255,.08); }
.int-input.is-dirty { border-color: var(--accent); }
.int-eye {
position: absolute; right: .5rem; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer; color: var(--gray-400);
font-size: .875rem; padding: .2rem; line-height: 1;
}
.int-eye:hover { color: var(--gray-700); }
/* ─── Page header (if not already defined) ───────────── */
.page-header { margin-bottom: 1.5rem; }
.page-title { font-size: 1.5rem; font-weight: 700; color: var(--gray-900); }
.page-subtitle { font-size: .9rem; color: var(--gray-500); margin-top: .25rem; }
/* ─── Form control alias ─────────────────────────────── */
.form-label { font-size: .8125rem; font-weight: 500; color: var(--gray-700); margin-bottom: .25rem; display: block; }
.form-control {
width: 100%; padding: .5rem .75rem;
border: 1.5px solid var(--gray-300); border-radius: var(--radius);
font-size: .9rem; background: var(--white); color: var(--gray-900); transition: border-color .2s;
}
.form-control:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(0,87,255,.08); }
/* ─── Modal ──────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 200;
display: flex; align-items: center; justify-content: center; padding: 1rem;
}
.modal {
background: var(--white); border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl); width: 100%; max-height: 90vh; overflow-y: auto;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--gray-200);
}
.modal-header h3 { font-size: 1.05rem; font-weight: 700; }
.modal-body { padding: 1.5rem; }
.modal-footer {
display: flex; justify-content: flex-end; gap: .75rem;
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);
}
.btn-icon { background: none; border: none; cursor: pointer; color: var(--gray-500); font-size: 1rem; padding: .25rem; line-height: 1; border-radius: var(--radius); }
.btn-icon:hover { background: var(--gray-100); color: var(--gray-800); }
/* ─── Empty state ────────────────────────────────────── */
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--gray-500); }
/* ─── Badge aliases ──────────────────────────────────── */
.badge-success { background: var(--green-light); color: #065f46; }
.badge-error { background: var(--red-light); color: #991b1b; }
.badge-warning { background: var(--yellow-light); color: #92400e; }
.badge-info { background: var(--blue-light); color: #1e40af; }
/* ─── Responsive ─────────────────────────────────────── */
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
+2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { WhatsAppFloat } from "./_components/whatsapp-float";
export const metadata: Metadata = {
title: "Moraworld Imports — Tu casillero en New Jersey para Ecuador",
@@ -445,6 +446,7 @@ export default function HomePage() {
<RolesSection />
<CompaniesSection />
<Footer />
<WhatsAppFloat />
</>
);
}
+84 -31
View File
@@ -3,96 +3,149 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
// §08 — 11 estados oficiales
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",
REGISTRADO: "Registrado",
EN_TRANSITO_BODEGA: "En tránsito a NJ",
RECIBIDO_BODEGA: "Recibido en NJ",
EN_VERIFICACION: "En verificación",
VERIFICADO: "Verificado",
DECLARACION_ADUANERA: "Declaración aduanera",
EN_TRANSITO_ECUADOR: "En tránsito a Ecuador",
EN_ADUANA_ECUADOR: "En aduana Ecuador",
LISTO_ENTREGA: "Listo para entrega",
ENTREGADO: "Entregado",
INCIDENCIA: "Incidencia",
};
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",
REGISTRADO: "badge-gray",
EN_TRANSITO_BODEGA: "badge-yellow",
RECIBIDO_BODEGA: "badge-blue",
EN_VERIFICACION: "badge-yellow",
VERIFICADO: "badge-green",
DECLARACION_ADUANERA: "badge-blue",
EN_TRANSITO_ECUADOR: "badge-orange",
EN_ADUANA_ECUADOR: "badge-red",
LISTO_ENTREGA: "badge-green",
ENTREGADO: "badge-green",
INCIDENCIA: "badge-red",
};
// Estados donde el cliente puede pagar
const PAYABLE_STATUSES = ["VERIFICADO", "DECLARACION_ADUANERA"];
export default function MisPaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
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));
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>;
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
return (
<div>
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: "1rem" }}>
<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 }}
className="form-input"
style={{ maxWidth: 280 }}
placeholder="Buscar por código o descripción..."
value={filter} onChange={e => setFilter(e.target.value)}
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>
<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 key={p.id} className="card" style={{ cursor: "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 ?? ""}
{p.description ?? "Sin descripción"}
{p.vendorTracking ? ` · ${p.vendorTracking}` : ""}
</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
{new Date(p.createdAt).toLocaleDateString("es-EC")}
{p.actualWeight ? ` · ${p.actualWeight} lb` : p.declaredWeight ? ` · ~${p.declaredWeight} lb (declarado)` : ""}
</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 style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".5rem" }}>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>
{STATUS_LABEL[p.status] ?? p.status}
</span>
{/* Botón de pago cuando corresponde */}
{PAYABLE_STATUSES.includes(p.status) && !p.paidAt && (
<Link
href={`/portal/pago?packageId=${p.id}`}
className="btn btn-primary"
style={{ fontSize: ".8rem", padding: ".35rem .8rem" }}
onClick={e => e.stopPropagation()}
>
💳 Pagar envío
</Link>
)}
{p.paidAt && (
<span style={{ fontSize: ".75rem", color: "var(--green)", fontWeight: 600 }}> Pagado</span>
)}
</div>
</div>
{/* Historial expandido */}
{/* Detalle expandido */}
{selected?.id === p.id && (
<div className="card-footer">
<div style={{ fontWeight: 600, marginBottom: ".75rem" }}>Historial de estados</div>
<div className="card-footer" style={{ borderTop: "1px solid var(--gray-100)", paddingTop: "1rem" }}>
<div style={{ fontWeight: 600, marginBottom: ".75rem", fontSize: ".9rem" }}>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 style={{ fontWeight: 600, fontSize: ".875rem" }}>
{STATUS_LABEL[h.status] ?? h.status}
</div>
{h.note && (
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{h.note}</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>}
) : (
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>Sin historial disponible.</p>
)}
</div>
)}
</div>
+199
View File
@@ -0,0 +1,199 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { api, getUser } from "@/lib/api";
// §09 paso 8 — Página de pago del envío
// URL: /portal/pago?packageId=xxx
const STATUS_LABEL: Record<string, { text: string; color: string }> = {
PENDIENTE: { text: "Pendiente de pago", color: "var(--yellow)" },
PROCESANDO: { text: "Procesando...", color: "var(--accent)" },
COMPLETADO: { text: "Pago completado", color: "var(--green)" },
FALLIDO: { text: "Pago fallido", color: "var(--red)" },
REEMBOLSADO: { text: "Reembolsado", color: "var(--gray-500)" },
};
function PagoContent() {
const router = useRouter();
const params = useSearchParams();
const packageId = params.get("packageId");
const user = getUser();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!user) { router.replace("/login"); return; }
if (!packageId) { router.replace("/portal/mis-paquetes"); return; }
api.payments.packageDetail(packageId)
.then(setData)
.catch(() => setError("No se pudo cargar el paquete."))
.finally(() => setLoading(false));
}, [packageId, router, user]);
const handlePay = async () => {
if (!data) return;
setPaying(true);
setError(null);
try {
// 1. Crear intent de pago
const intent = await api.payments.createIntent(data.package.id);
// 2. En producción aquí se abre Stripe Checkout/PayPhone; en dev confirmamos directamente
await api.payments.confirm(intent.id);
setSuccess(true);
// Recargar datos
const fresh = await api.payments.packageDetail(packageId!);
setData(fresh);
} catch (e: any) {
setError(e?.message ?? "Error al procesar el pago.");
} finally {
setPaying(false);
}
};
if (loading) {
return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "60vh" }}>
<div className="spinner" />
</div>
);
}
if (error && !data) {
return (
<div style={{ maxWidth: 480, margin: "4rem auto", textAlign: "center" }}>
<div style={{ fontSize: "2rem", marginBottom: ".75rem" }}></div>
<p style={{ color: "var(--gray-600)" }}>{error}</p>
<button className="btn btn-ghost" style={{ marginTop: "1rem" }} onClick={() => router.back()}>Volver</button>
</div>
);
}
const { package: pkg, payment, breakdown } = data ?? {};
const payStatus = payment?.status;
const alreadyPaid = payStatus === "COMPLETADO";
return (
<div style={{ maxWidth: 560, margin: "0 auto", padding: "2rem 1rem" }}>
<button
className="btn btn-ghost"
style={{ marginBottom: "1.5rem", fontSize: ".85rem" }}
onClick={() => router.back()}
>
Volver
</button>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: ".25rem" }}>Pago del envío</h1>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem", marginBottom: "1.5rem" }}>
Tracking: <code style={{ fontWeight: 700, color: "var(--primary)" }}>{pkg?.trackingId}</code>
</p>
{/* Estado actual del pago */}
{payment && (
<div style={{
display: "flex", alignItems: "center", gap: ".75rem",
padding: ".875rem 1rem", borderRadius: 8, marginBottom: "1.5rem",
background: "var(--gray-50)", border: "1px solid var(--gray-200)",
}}>
<span style={{
width: 10, height: 10, borderRadius: "50%", flexShrink: 0,
background: STATUS_LABEL[payStatus]?.color ?? "var(--gray-400)",
}} />
<span style={{ fontWeight: 600 }}>{STATUS_LABEL[payStatus]?.text ?? payStatus}</span>
{payment.paidAt && (
<span style={{ marginLeft: "auto", fontSize: ".8rem", color: "var(--gray-500)" }}>
{new Date(payment.paidAt).toLocaleString("es-EC")}
</span>
)}
</div>
)}
{/* Desglose de costos */}
<div className="card" style={{ marginBottom: "1.5rem" }}>
<div className="card-header"><span style={{ fontWeight: 700 }}>Desglose de costos (§15)</span></div>
<div className="card-body">
{[
["Peso", `${breakdown?.weightLb ?? 0} lb`],
["Precio por libra", `$${breakdown?.pricePerLb ?? 0}`],
["Flete", `$${breakdown?.freight ?? 0}`],
["Seguro (2%)", `$${breakdown?.insurance ?? 0}`],
["FODINFA (0.5%)", `$${breakdown?.fodinfa ?? 0}`],
].map(([k, v]) => (
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".4rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-600)", fontSize: ".9rem" }}>{k}</span>
<span style={{ fontSize: ".9rem" }}>{v}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", padding: ".75rem 0 0", marginTop: ".25rem" }}>
<span style={{ fontWeight: 700, fontSize: "1rem" }}>Total a pagar</span>
<span style={{ fontWeight: 700, fontSize: "1.15rem", color: "var(--primary)" }}>
${breakdown?.total ?? 0} USD
</span>
</div>
</div>
</div>
{/* Descripción del paquete */}
<div style={{ padding: ".875rem 1rem", background: "var(--gray-50)", borderRadius: 8, marginBottom: "1.5rem", fontSize: ".875rem", color: "var(--gray-600)" }}>
<strong>Descripción:</strong> {pkg?.description}<br />
<strong>Valor declarado:</strong> ${pkg?.declaredValue} USD
</div>
{/* Alerta de éxito */}
{(success || alreadyPaid) && (
<div style={{ background: "#F0FDF4", border: "1px solid #86EFAC", borderRadius: 8, padding: "1rem", marginBottom: "1.5rem", display: "flex", gap: ".75rem", alignItems: "flex-start" }}>
<span style={{ fontSize: "1.3rem" }}></span>
<div>
<div style={{ fontWeight: 700, color: "#166534" }}>¡Pago completado!</div>
<div style={{ fontSize: ".85rem", color: "#15803D" }}>Tu envío fue confirmado y está siendo procesado.</div>
</div>
</div>
)}
{/* Error */}
{error && (
<div style={{ background: "#FEF2F2", border: "1px solid #FCA5A5", borderRadius: 8, padding: ".875rem 1rem", marginBottom: "1rem", fontSize: ".875rem", color: "#991B1B" }}>
{error}
</div>
)}
{/* Botón de pago */}
{!alreadyPaid && (
<button
className="btn btn-primary"
style={{ width: "100%", padding: "1rem", fontSize: "1rem", fontWeight: 700 }}
onClick={handlePay}
disabled={paying}
>
{paying ? "Procesando pago..." : `Pagar $${breakdown?.total ?? 0} USD`}
</button>
)}
{alreadyPaid && (
<button
className="btn btn-primary"
style={{ width: "100%" }}
onClick={() => router.push("/portal/mis-paquetes")}
>
Ver mis paquetes
</button>
)}
<p style={{ marginTop: "1rem", fontSize: ".75rem", color: "var(--gray-400)", textAlign: "center" }}>
Pago seguro · Stripe / PayPhone · Los datos de tu tarjeta nunca se almacenan en nuestros servidores.
</p>
</div>
);
}
export default function PagoPage() {
return (
<Suspense fallback={<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}><div className="spinner" /></div>}>
<PagoContent />
</Suspense>
);
}
+15
View File
@@ -154,4 +154,19 @@ export const api = {
batchUpsert: (items: Array<{ key: string; value: string | null; isActive: boolean }>) =>
request<any>("/integrations/batch", { method: "PUT", body: JSON.stringify({ items }) }),
},
notificationTemplates: {
list: () => request<any[]>("/notification-templates"),
seed: () => request<any>("/notification-templates/seed", { method: "POST" }),
update: (id: string, body: { body: string; subject?: string; isActive?: boolean }) =>
request<any>(`/notification-templates/${id}`, { method: "PUT", body: JSON.stringify(body) }),
},
payments: {
list: (status?: string) => request<any[]>(`/payments${status ? `?status=${status}` : ""}`),
packageDetail: (packageId: string) => request<any>(`/payments/package/${packageId}`),
byTracking: (trackingId: string) => request<any>(`/payments/track/${trackingId}`),
createIntent: (packageId: string, provider = "stripe") =>
request<any>("/payments/intent", { method: "POST", body: JSON.stringify({ packageId, provider }) }),
confirm: (paymentId: string) =>
request<any>(`/payments/${paymentId}/confirm`, { method: "POST" }),
},
};