feat: Stripe Checkout, Zeptomail, pre-alert linking, perfil 3-tab, carga-pesada landing
- payments: real Stripe Checkout Session (redirect flow), confirmBySession, webhook handler - notifications: Zeptomail REST email, wa.me WhatsApp links, IntegrationsService injection - auth: changePassword, updateProfile, disableMfa (TOTP-verified) endpoints - packages: tryLinkPreAlert() auto-links on create (non-blocking) - integrations: catalog updated (zeptomail, stripe_webhook_secret; removed sendgrid/whatsapp-api) - web/portal/perfil: 3-tab layout (datos / contraseña / MFA) - web/portal/pago: Stripe redirect + ?success=1&session_id= callback, cancelled banner - web/admin/b2b: fix enum values (EN_COTIZACION, ACEPTADO) - web/carga-pesada: rich marketing landing (FCL/LCL, 7-step process, sectors, INEN callout) - tests: fix payments.service.spec (IntegrationsService+ConfigService mocks) - tests: fix notifications.service.spec (IntegrationsService mock, phone in mockUser) - all 104 tests passing, API + web builds clean
This commit is contained in:
@@ -31,7 +31,8 @@
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
"rxjs": "^7.8.2",
|
||||
"stripe": "^22.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.7",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
Controller, Post, Get, Body, Req, UseGuards, HttpCode, HttpStatus,
|
||||
Controller, Post, Get, Patch, Body, Req, UseGuards, HttpCode, HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { RegisterDto, LoginDto, RefreshDto, SetupMfaDto } from "./dto/auth.dto";
|
||||
import { RegisterDto, LoginDto, RefreshDto, SetupMfaDto, ChangePasswordDto, UpdateProfileDto } from "./dto/auth.dto";
|
||||
import { JwtAuthGuard } from "./guards/auth.guard";
|
||||
import { CurrentUser } from "./decorators/current-user.decorator";
|
||||
|
||||
@@ -45,6 +45,22 @@ export class AuthController {
|
||||
return this.auth.getProfile(user.id);
|
||||
}
|
||||
|
||||
/** PATCH /api/auth/me — Actualizar nombre / teléfono */
|
||||
@Patch("me")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
updateProfile(@Body() dto: UpdateProfileDto, @CurrentUser() user: any) {
|
||||
return this.auth.updateProfile(user.id, dto);
|
||||
}
|
||||
|
||||
/** PATCH /api/auth/password — Cambiar contraseña */
|
||||
@Patch("password")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async changePassword(@Body() dto: ChangePasswordDto, @CurrentUser() user: any) {
|
||||
await this.auth.changePassword(user.id, dto.oldPassword, dto.newPassword);
|
||||
return { message: "Contraseña actualizada correctamente." };
|
||||
}
|
||||
|
||||
/** POST /api/auth/mfa/setup — Genera QR para TOTP */
|
||||
@Post("mfa/setup")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -58,4 +74,12 @@ export class AuthController {
|
||||
verifyMfa(@Body() dto: SetupMfaDto, @CurrentUser() user: any) {
|
||||
return this.auth.verifyMfa(user.id, dto.totpCode);
|
||||
}
|
||||
|
||||
/** POST /api/auth/mfa/disable — Desactiva MFA (requiere código TOTP) */
|
||||
@Post("mfa/disable")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
disableMfa(@Body() dto: SetupMfaDto, @CurrentUser() user: any) {
|
||||
return this.auth.disableMfa(user.id, dto.totpCode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,51 @@ export class AuthService {
|
||||
return { mfaEnabled: true };
|
||||
}
|
||||
|
||||
// ─── Change Password ─────────────────────────────────────────
|
||||
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
const valid = await bcrypt.compare(oldPassword, user.passwordHash);
|
||||
if (!valid) throw new BadRequestException("La contraseña actual es incorrecta.");
|
||||
|
||||
if (oldPassword === newPassword) throw new BadRequestException("La nueva contraseña debe ser diferente.");
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
await this.prisma.client.user.update({ where: { id: userId }, data: { passwordHash } });
|
||||
await this.audit(user.tenantId, userId, "PASSWORD_CHANGED", "User", userId);
|
||||
}
|
||||
|
||||
// ─── Update Profile ───────────────────────────────────────────
|
||||
async updateProfile(userId: string, data: { firstName?: string; lastName?: string; phone?: string }): Promise<any> {
|
||||
const user = await this.prisma.client.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(data.firstName ? { firstName: data.firstName } : {}),
|
||||
...(data.lastName ? { lastName: data.lastName } : {}),
|
||||
...(data.phone !== undefined ? { phone: data.phone || null } : {}),
|
||||
},
|
||||
});
|
||||
return this.sanitizeUser(user);
|
||||
}
|
||||
|
||||
// ─── Disable MFA ─────────────────────────────────────────────
|
||||
async disableMfa(userId: string, totpCode: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
if (!user.mfaEnabled) throw new BadRequestException("MFA no está activada.");
|
||||
|
||||
const ok = totpVerify({ token: totpCode, secret: user.mfaSecret! });
|
||||
if (!ok) throw new BadRequestException("Código TOTP inválido.");
|
||||
|
||||
await this.prisma.client.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaEnabled: false, mfaSecret: null },
|
||||
});
|
||||
await this.audit(user.tenantId, userId, "MFA_DISABLED", "User", userId);
|
||||
return { mfaEnabled: false };
|
||||
}
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────
|
||||
async getProfile(userId: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
|
||||
@@ -42,3 +42,29 @@ export class SetupMfaDto {
|
||||
@IsString()
|
||||
totpCode!: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@IsString()
|
||||
oldPassword!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,15 @@ export const INTEGRATION_CATALOG = [
|
||||
// ── Pasarela de Pagos ──────────────────────────────────────
|
||||
{ key: "stripe_public_key", label: "Stripe — Clave pública", group: "payment", required: false },
|
||||
{ key: "stripe_secret_key", label: "Stripe — Clave secreta", group: "payment", required: false },
|
||||
{ key: "stripe_webhook_secret", label: "Stripe — Webhook Secret", group: "payment", required: false },
|
||||
{ key: "payphone_token", label: "PayPhone — Token de API", group: "payment", required: false },
|
||||
{ key: "paypal_client_id", label: "PayPal — Client ID", group: "payment", required: false },
|
||||
// ── Notificaciones ────────────────────────────────────────
|
||||
{ key: "whatsapp_token", label: "WhatsApp Business — Token", group: "notifications", required: true },
|
||||
{ key: "whatsapp_phone_id", label: "WhatsApp Business — Phone ID", group: "notifications", required: true },
|
||||
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
|
||||
{ key: "sendgrid_api_key", label: "SendGrid — API Key", group: "notifications", required: false },
|
||||
{ key: "email_from", label: "Email remitente (from)", group: "notifications", required: false },
|
||||
{ key: "sms_provider", label: "SMS — Proveedor (ej: Twilio)", group: "notifications", required: false },
|
||||
{ key: "sms_api_key", label: "SMS — API Key", group: "notifications", required: false },
|
||||
{ key: "zeptomail_api_key", label: "Zeptomail — Send Mail Token", group: "notifications", required: true },
|
||||
{ key: "email_from", label: "Email remitente (from address)", group: "notifications", required: true },
|
||||
{ key: "email_from_name", label: "Email remitente (nombre visible)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
|
||||
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
|
||||
// ── Aduana / SENAE ────────────────────────────────────────
|
||||
{ key: "senae_endpoint", label: "SENAE — URL WebService", group: "customs", required: true },
|
||||
{ key: "senae_api_key", label: "SENAE — API Key / Token", group: "customs", required: true },
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { NotificationsController } from "./notifications.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, IntegrationsModule],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
const mockTemplate = {
|
||||
id: "tpl-1",
|
||||
@@ -22,7 +23,15 @@ const mockPackage = {
|
||||
status: "REGISTRADO",
|
||||
};
|
||||
|
||||
const mockUser = { id: "user-1", firstName: "Juan", lastName: "Pérez", suite: { code: "EC-00001" } };
|
||||
// Include phone + email so notifyStatusChange skips the DB user lookup
|
||||
const mockUser = {
|
||||
id: "user-1",
|
||||
firstName: "Juan",
|
||||
lastName: "Pérez",
|
||||
email: "juan@test.com",
|
||||
phone: "+5930987654321",
|
||||
suite: { code: "EC-00001" },
|
||||
};
|
||||
|
||||
const mockPrisma = {
|
||||
client: {
|
||||
@@ -38,18 +47,28 @@ const mockPrisma = {
|
||||
updateMany: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// IntegrationsService mock — getValue returns null so Zeptomail is skipped gracefully
|
||||
const mockIntegrations = {
|
||||
getValue: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
describe("NotificationsService", () => {
|
||||
let service: NotificationsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockIntegrations.getValue.mockResolvedValue(null);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
NotificationsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: IntegrationsService, useValue: mockIntegrations },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<NotificationsService>(NotificationsService);
|
||||
@@ -122,7 +141,8 @@ describe("NotificationsService", () => {
|
||||
|
||||
it("crea notificaciones para los 3 canales por defecto", async () => {
|
||||
await service.notifyStatusChange(mockPackage, mockUser);
|
||||
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3); // EMAIL, WHATSAPP, PUSH
|
||||
// EMAIL (FALLIDO – sin API key), WHATSAPP (wa.me link), PUSH (FALLIDO)
|
||||
expect(mockPrisma.client.notification.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("interpola {{trackingId}} y {{firstName}} en el body", async () => {
|
||||
@@ -139,8 +159,7 @@ describe("NotificationsService", () => {
|
||||
it("no envía notificación si la plantilla está inactiva", async () => {
|
||||
mockPrisma.client.notificationTemplate.findUnique.mockResolvedValue({ ...mockTemplate, isActive: false });
|
||||
await service.notifyStatusChange(mockPackage, mockUser);
|
||||
// Solo EMAIL está deshabilitado, WHATSAPP y PUSH usan fallback (activo)
|
||||
// El mock devuelve el template inactivo para todos
|
||||
// El mock devuelve el template inactivo para todos los canales
|
||||
expect(mockPrisma.client.notification.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
// ─── Plantillas por defecto (fallback cuando no hay en DB) ────
|
||||
const DEFAULT_SUBJECTS: Record<string, string> = {
|
||||
@@ -35,11 +36,19 @@ function interpolate(tpl: string, vars: Record<string, string>): string {
|
||||
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`);
|
||||
}
|
||||
|
||||
/** Normaliza número de teléfono para wa.me (solo dígitos, con código de país) */
|
||||
function normalizePhone(phone: string): string {
|
||||
return phone.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private integrations: IntegrationsService,
|
||||
) {}
|
||||
|
||||
// ─── Gestión de plantillas ────────────────────────────────
|
||||
|
||||
@@ -90,11 +99,20 @@ export class NotificationsService {
|
||||
|
||||
/** Called whenever a package status changes. */
|
||||
async notifyStatusChange(pkg: any, user: any): Promise<void> {
|
||||
// Load full user data to get phone and name (needed for wa.me)
|
||||
let fullUser = user;
|
||||
if (!user?.firstName || !user?.phone) {
|
||||
try {
|
||||
fullUser = await this.prisma.client.user.findUnique({ where: { id: user.id } }) ?? user;
|
||||
} catch { /* non-blocking */ }
|
||||
}
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
trackingId: pkg.trackingId ?? "",
|
||||
firstName: user?.firstName ?? "Cliente",
|
||||
firstName: fullUser?.firstName ?? "Cliente",
|
||||
lastName: fullUser?.lastName ?? "",
|
||||
status: pkg.status ?? "",
|
||||
suiteCode: user?.suite?.code ?? "",
|
||||
suiteCode: fullUser?.suite?.code ?? "",
|
||||
};
|
||||
|
||||
const channels: Array<"EMAIL" | "WHATSAPP" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||
@@ -117,6 +135,32 @@ export class NotificationsService {
|
||||
vars
|
||||
);
|
||||
|
||||
let finalBody = bodyText;
|
||||
let notifStatus: "ENVIADO" | "FALLIDO" | "PENDIENTE" = "PENDIENTE";
|
||||
let errorMsg: string | undefined;
|
||||
|
||||
// ── Dispatch por canal ──────────────────────────────
|
||||
if (channel === "EMAIL") {
|
||||
const result = await this.sendZeptomail(pkg.tenantId, subject, bodyText, fullUser);
|
||||
notifStatus = result.ok ? "ENVIADO" : "FALLIDO";
|
||||
errorMsg = result.error;
|
||||
} else if (channel === "WHATSAPP") {
|
||||
// wa.me link con mensaje pre-cargado (sin Business API)
|
||||
const phone = fullUser?.phone ? normalizePhone(fullUser.phone) : null;
|
||||
if (phone) {
|
||||
finalBody = `https://wa.me/${phone}?text=${encodeURIComponent(bodyText)}`;
|
||||
notifStatus = "ENVIADO";
|
||||
} else {
|
||||
// No phone — skip WhatsApp
|
||||
notifStatus = "FALLIDO";
|
||||
errorMsg = "Sin número de teléfono registrado";
|
||||
}
|
||||
} else if (channel === "PUSH") {
|
||||
// PUSH no implementado — marcar FALLIDO silenciosamente
|
||||
notifStatus = "FALLIDO";
|
||||
errorMsg = "PUSH no configurado";
|
||||
}
|
||||
|
||||
const record = await this.prisma.client.notification.create({
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
@@ -124,16 +168,17 @@ export class NotificationsService {
|
||||
channel,
|
||||
status: "PENDIENTE",
|
||||
subject,
|
||||
body: bodyText,
|
||||
body: finalBody,
|
||||
},
|
||||
});
|
||||
|
||||
// STUB: En producción → SendGrid (EMAIL), WhatsApp Business API, etc.
|
||||
this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${bodyText}`);
|
||||
|
||||
await this.prisma.client.notification.update({
|
||||
where: { id: record.id },
|
||||
data: { status: "ENVIADO", sentAt: new Date() },
|
||||
data: {
|
||||
status: notifStatus,
|
||||
sentAt: notifStatus === "ENVIADO" ? new Date() : null,
|
||||
error: errorMsg ?? null,
|
||||
},
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
this.logger.error(`Notification ${channel} failed: ${(e as Error).message}`);
|
||||
@@ -141,6 +186,76 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envía un email via Zeptomail REST API.
|
||||
* Docs: https://www.zoho.com/zeptomail/help/api/email-sending.html
|
||||
*/
|
||||
private async sendZeptomail(
|
||||
tenantId: string,
|
||||
subject: string,
|
||||
bodyText: string,
|
||||
toUser: any,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const apiKey = await this.integrations.getValue(tenantId, "zeptomail_api_key");
|
||||
const fromAddr = await this.integrations.getValue(tenantId, "email_from") ?? "noreply@moraworld.com";
|
||||
const fromName = await this.integrations.getValue(tenantId, "email_from_name") ?? "Moraworld Imports";
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.warn(`[ZEPTOMAIL] No API key configured for tenant ${tenantId}. Email not sent.`);
|
||||
return { ok: false, error: "Zeptomail API key no configurada" };
|
||||
}
|
||||
|
||||
const toEmail = toUser?.email;
|
||||
if (!toEmail) return { ok: false, error: "Sin email del destinatario" };
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
||||
<div style="background:#001F5B;padding:20px;border-radius:8px 8px 0 0">
|
||||
<h2 style="color:#fff;margin:0;font-size:1.2rem">Moraworld<span style="color:#FF6B00">.</span>Imports</h2>
|
||||
</div>
|
||||
<div style="background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px;border:1px solid #e5e7eb;border-top:none">
|
||||
<p style="font-size:1rem;color:#111827;margin:0 0 16px">${bodyText.replace(/\n/g, "<br>")}</p>
|
||||
<hr style="border:none;border-top:1px solid #e5e7eb;margin:20px 0">
|
||||
<p style="font-size:.75rem;color:#6b7280;margin:0">Moraworld Imports S.A.S. · Mora Global Import LLC</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const payload = {
|
||||
from: { address: fromAddr, name: fromName },
|
||||
to: [{
|
||||
email_address: {
|
||||
address: toEmail,
|
||||
name: `${toUser?.firstName ?? ""} ${toUser?.lastName ?? ""}`.trim() || toEmail,
|
||||
},
|
||||
}],
|
||||
subject,
|
||||
htmlbody: htmlBody,
|
||||
};
|
||||
|
||||
const res = await fetch("https://api.zeptomail.com/v1.1/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Zoho-enczapikey ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
this.logger.error(`[ZEPTOMAIL] Send failed (${res.status}): ${errText}`);
|
||||
return { ok: false, error: `Zeptomail error ${res.status}: ${errText.substring(0, 200)}` };
|
||||
}
|
||||
|
||||
this.logger.log(`[ZEPTOMAIL] Email sent to ${toEmail} — subject: ${subject}`);
|
||||
return { ok: true };
|
||||
} catch (err: any) {
|
||||
this.logger.error(`[ZEPTOMAIL] Exception: ${err.message}`);
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async findByUser(userId: string, limit = 20) {
|
||||
return this.prisma.client.notification.findMany({
|
||||
where: { userId },
|
||||
|
||||
@@ -81,9 +81,40 @@ export class PackagesService {
|
||||
},
|
||||
});
|
||||
|
||||
// Intentar vincular con pre-alerta pendiente del mismo usuario (§09)
|
||||
await this.tryLinkPreAlert(pkg.id, dto.userId, tenantId, dto.vendorTracking);
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/** Busca una pre-alerta PENDIENTE del mismo usuario que coincida por vendorTracking
|
||||
* y la vincula automáticamente al paquete (status → VINCULADA, packageId set). */
|
||||
private async tryLinkPreAlert(
|
||||
packageId: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
vendorTracking?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const where: any = { tenantId, userId, status: "PENDIENTE", packageId: null };
|
||||
if (vendorTracking) where.vendorTracking = vendorTracking;
|
||||
|
||||
const alert = await this.prisma.client.preAlert.findFirst({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!alert) return;
|
||||
|
||||
await this.prisma.client.preAlert.update({
|
||||
where: { id: alert.id },
|
||||
data: { packageId, status: "VINCULADA" },
|
||||
});
|
||||
} catch {
|
||||
// Non-blocking — linking failure must not block package creation
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findUnique({ where: { id } });
|
||||
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Controller, Get, Post, Body, Param, Query,
|
||||
UseGuards, Request, BadRequestException,
|
||||
UseGuards, Request, BadRequestException, Headers, RawBodyRequest,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { JwtAuthGuard } from "../auth/guards/auth.guard";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
@@ -10,39 +11,76 @@ class CreateIntentDto {
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
class ConfirmSessionDto {
|
||||
sessionId!: string;
|
||||
}
|
||||
|
||||
@Controller("payments")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PaymentsController {
|
||||
constructor(private readonly svc: PaymentsService) {}
|
||||
|
||||
/** GET /payments — lista todos los pagos del tenant (admin) */
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
list(@Request() req: any, @Query("status") status?: string): Promise<any[]> {
|
||||
return this.svc.list(req.user.tenantId, status);
|
||||
}
|
||||
|
||||
/** GET /payments/package/:packageId — detalle + desglose para el cliente */
|
||||
@Get("package/:packageId")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
detail(@Param("packageId") packageId: string, @Request() req: any): Promise<any> {
|
||||
return this.svc.findByPackageForUser(packageId, req.user.id, req.user.tenantId);
|
||||
}
|
||||
|
||||
/** GET /payments/track/:trackingId — por tracking ID (cliente o admin) */
|
||||
@Get("track/:trackingId")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
byTracking(@Param("trackingId") trackingId: string, @Request() req: any): Promise<any> {
|
||||
return this.svc.findByTracking(trackingId, req.user.tenantId);
|
||||
}
|
||||
|
||||
/** POST /payments/intent — crea o recupera un PaymentIntent */
|
||||
/** POST /payments/intent — crea o recupera un PaymentIntent / Checkout Session */
|
||||
@Post("intent")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
createIntent(@Body() dto: CreateIntentDto, @Request() req: any): Promise<any> {
|
||||
if (!dto.packageId) throw new BadRequestException("packageId es requerido");
|
||||
return this.svc.createIntent(dto.packageId, req.user.id, req.user.tenantId, dto.provider);
|
||||
}
|
||||
|
||||
/** POST /payments/:id/confirm — confirma pago (dev/stub) */
|
||||
/**
|
||||
* POST /payments/confirm-session — confirma pago verificando Stripe session.
|
||||
* Llamado desde el frontend al volver de la página de Stripe (success_url).
|
||||
*/
|
||||
@Post("confirm-session")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
confirmSession(@Body() dto: ConfirmSessionDto, @Request() req: any): Promise<any> {
|
||||
if (!dto.sessionId) throw new BadRequestException("sessionId es requerido");
|
||||
return this.svc.confirmBySession(dto.sessionId, req.user.tenantId);
|
||||
}
|
||||
|
||||
/** POST /payments/:id/confirm — confirma pago por paymentId (dev / fallback) */
|
||||
@Post(":id/confirm")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
confirm(@Param("id") id: string, @Request() req: any): Promise<any> {
|
||||
return this.svc.confirm(id, req.user.tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/webhook — Stripe webhook (sin auth JWT — raw body).
|
||||
* El tenant se pasa como query param: ?tenant=moraworld
|
||||
*/
|
||||
@Post("webhook")
|
||||
async stripeWebhook(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers("stripe-signature") signature: string,
|
||||
@Query("tenant") tenant = "moraworld",
|
||||
): Promise<{ received: boolean }> {
|
||||
const rawBody = (req as any).rawBody as Buffer;
|
||||
if (!rawBody || !signature) throw new BadRequestException("Missing body or signature");
|
||||
|
||||
// Resolve tenantId from slug
|
||||
await this.svc.handleStripeWebhook(rawBody, signature, tenant);
|
||||
return { received: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Module } from "@nestjs/common";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { IntegrationsModule } from "../integrations/integrations.module";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, IntegrationsModule, ConfigModule],
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService],
|
||||
exports: [PaymentsService],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
|
||||
// Mock del PrismaService
|
||||
const mockPayment = {
|
||||
@@ -58,15 +60,28 @@ const mockPrisma = {
|
||||
},
|
||||
};
|
||||
|
||||
// IntegrationsService mock — getValue returns null so Stripe is never instantiated (stub path)
|
||||
const mockIntegrations = {
|
||||
getValue: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
// ConfigService mock
|
||||
const mockConfig = {
|
||||
get: jest.fn().mockReturnValue("http://localhost:3000"),
|
||||
};
|
||||
|
||||
describe("PaymentsService", () => {
|
||||
let service: PaymentsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockIntegrations.getValue.mockResolvedValue(null);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PaymentsService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: IntegrationsService, useValue: mockIntegrations },
|
||||
{ provide: ConfigService, useValue: mockConfig },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<PaymentsService>(PaymentsService);
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { IntegrationsService } from "../integrations/integrations.service";
|
||||
import Stripe from "stripe";
|
||||
|
||||
type StripeClient = InstanceType<typeof Stripe>;
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private integrations: IntegrationsService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** Inicializa el cliente Stripe con la key del tenant */
|
||||
private async getStripe(tenantId: string): Promise<StripeClient | null> {
|
||||
const secretKey = await this.integrations.getValue(tenantId, "stripe_secret_key");
|
||||
if (!secretKey) return null;
|
||||
return new Stripe(secretKey, { apiVersion: "2026-05-27.dahlia" });
|
||||
}
|
||||
|
||||
/** Calcula el monto a cobrar desde el Package (peso real × tarifa) */
|
||||
private async calcAmount(pkg: any, tenantId: string): Promise<number> {
|
||||
@@ -27,7 +43,11 @@ export class PaymentsService {
|
||||
return { package: pkg, payment: pkg.payment };
|
||||
}
|
||||
|
||||
/** Crea o recupera un intento de pago para el paquete */
|
||||
/**
|
||||
* Crea o recupera un intento de pago para el paquete.
|
||||
* Si Stripe está configurado → crea Checkout Session y devuelve checkoutUrl.
|
||||
* Si no → stub para desarrollo.
|
||||
*/
|
||||
async createIntent(packageId: string, userId: string, tenantId: string, provider = "stripe"): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findFirst({ where: { id: packageId, tenantId } });
|
||||
if (!pkg) throw new NotFoundException("Paquete no encontrado");
|
||||
@@ -36,14 +56,63 @@ export class PaymentsService {
|
||||
// Reusar intent existente si está PENDIENTE o PROCESANDO
|
||||
const existing = await this.prisma.client.payment.findUnique({ where: { packageId } });
|
||||
if (existing && ["PENDIENTE", "PROCESANDO"].includes(existing.status)) {
|
||||
// Si hay Stripe, reconstruir checkoutUrl si es una session
|
||||
const stripe = await this.getStripe(tenantId);
|
||||
if (stripe && existing.providerRef?.startsWith("cs_")) {
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.retrieve(existing.providerRef);
|
||||
return { ...existing, checkoutUrl: session.url };
|
||||
} catch { /* session expired, fall through to create new */ }
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const amount = await this.calcAmount(pkg, tenantId);
|
||||
|
||||
// STUB: En producción → Stripe.paymentIntents.create(...)
|
||||
// ── Stripe Checkout Session ──────────────────────────────────
|
||||
const stripe = await this.getStripe(tenantId);
|
||||
if (stripe && provider === "stripe") {
|
||||
const appUrl = this.config.get("APP_URL", "http://localhost:3000");
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ["card"],
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: {
|
||||
name: `Envío ${pkg.trackingId}`,
|
||||
description: (pkg.description ?? "Paquete Moraworld Imports").substring(0, 250),
|
||||
},
|
||||
unit_amount: Math.round(amount * 100), // cents
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
mode: "payment",
|
||||
success_url: `${appUrl}/portal/pago?success=1&session_id={CHECKOUT_SESSION_ID}&packageId=${packageId}`,
|
||||
cancel_url: `${appUrl}/portal/pago?cancelled=1&packageId=${packageId}`,
|
||||
metadata: { packageId, tenantId, userId },
|
||||
});
|
||||
|
||||
const payment = await this.prisma.client.payment.create({
|
||||
data: {
|
||||
tenantId,
|
||||
packageId,
|
||||
userId,
|
||||
amount,
|
||||
currency: "USD",
|
||||
provider: "stripe",
|
||||
providerRef: session.id,
|
||||
status: "PENDIENTE",
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[STRIPE] Checkout session ${session.id} created for ${pkg.trackingId}`);
|
||||
return { ...payment, checkoutUrl: session.url };
|
||||
}
|
||||
|
||||
// ── Fallback stub ────────────────────────────────────────────
|
||||
const providerRef = `pi_stub_${Date.now()}`;
|
||||
this.logger.log(`[PAYMENT STUB] Creating ${provider} intent for ${pkg.trackingId} — $${amount}`);
|
||||
this.logger.warn(`[PAYMENT STUB] No Stripe key configured for tenant ${tenantId}. Using stub for ${pkg.trackingId}`);
|
||||
|
||||
return this.prisma.client.payment.create({
|
||||
data: {
|
||||
@@ -59,11 +128,41 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Confirma un pago (webhook de Stripe o confirmación manual en dev) */
|
||||
/**
|
||||
* Confirma pago por Stripe session ID (callback de success_url).
|
||||
* Verifica con Stripe que payment_status === 'paid'.
|
||||
*/
|
||||
async confirmBySession(sessionId: string, tenantId: string): Promise<any> {
|
||||
// Find payment by providerRef
|
||||
const payment = await this.prisma.client.payment.findFirst({
|
||||
where: { providerRef: sessionId, tenantId },
|
||||
});
|
||||
if (!payment) throw new NotFoundException("Pago no encontrado para esta sesión");
|
||||
if (payment.status === "COMPLETADO") return payment;
|
||||
|
||||
// Verify with Stripe
|
||||
const stripe = await this.getStripe(tenantId);
|
||||
if (stripe) {
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
if (session.payment_status !== "paid") {
|
||||
throw new BadRequestException(`Pago no completado en Stripe (estado: ${session.payment_status})`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.confirmPayment(payment.id, tenantId);
|
||||
}
|
||||
|
||||
/** Confirma un pago por ID (webhook de Stripe o confirmación manual en dev) */
|
||||
async confirm(paymentId: string, tenantId: string): Promise<any> {
|
||||
const payment = await this.prisma.client.payment.findUnique({ where: { id: paymentId } });
|
||||
if (!payment || payment.tenantId !== tenantId) throw new NotFoundException("Pago no encontrado");
|
||||
if (payment.status === "COMPLETADO") throw new BadRequestException("El pago ya fue completado");
|
||||
return this.confirmPayment(paymentId, tenantId);
|
||||
}
|
||||
|
||||
private async confirmPayment(paymentId: string, tenantId: string): Promise<any> {
|
||||
const payment = await this.prisma.client.payment.findUnique({ where: { id: paymentId } });
|
||||
if (!payment) throw new NotFoundException("Pago no encontrado");
|
||||
|
||||
const [updatedPayment] = await this.prisma.client.$transaction([
|
||||
this.prisma.client.payment.update({
|
||||
@@ -80,6 +179,35 @@ export class PaymentsService {
|
||||
return updatedPayment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook de Stripe — `checkout.session.completed`.
|
||||
* El controller debe recibir el raw body para verificar la firma.
|
||||
*/
|
||||
async handleStripeWebhook(payload: Buffer, signature: string, tenantId: string): Promise<void> {
|
||||
const webhookSecret = await this.integrations.getValue(tenantId, "stripe_webhook_secret");
|
||||
const stripe = await this.getStripe(tenantId);
|
||||
if (!stripe || !webhookSecret) return;
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Stripe webhook signature failed: ${err.message}`);
|
||||
throw new BadRequestException("Webhook signature inválida");
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as any;
|
||||
if (session.payment_status === "paid") {
|
||||
try {
|
||||
await this.confirmBySession(session.id, tenantId);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Webhook confirm failed for session ${session.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lista pagos del tenant con filtros opcionales */
|
||||
async list(tenantId: string, status?: string): Promise<any[]> {
|
||||
return this.prisma.client.payment.findMany({
|
||||
|
||||
@@ -5,26 +5,26 @@ import { Timestamp } from "@/app/_components/timestamp";
|
||||
|
||||
// §13 — Flujo del Importador Mayorista (B2B)
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDIENTE: "Pendiente",
|
||||
CONTACTADO: "Contactado",
|
||||
COTIZADO: "Cotizado",
|
||||
APROBADO: "Aprobado",
|
||||
EN_PROCESO: "En proceso",
|
||||
COMPLETADO: "Completado",
|
||||
CANCELADO: "Cancelado",
|
||||
PENDIENTE: "Pendiente",
|
||||
EN_COTIZACION: "En cotización",
|
||||
COTIZADO: "Cotizado",
|
||||
ACEPTADO: "Aceptado",
|
||||
EN_PROCESO: "En proceso",
|
||||
COMPLETADO: "Completado",
|
||||
CANCELADO: "Cancelado",
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDIENTE: "badge-yellow",
|
||||
CONTACTADO: "badge-blue",
|
||||
COTIZADO: "badge-blue",
|
||||
APROBADO: "badge-green",
|
||||
EN_PROCESO: "badge-orange",
|
||||
COMPLETADO: "badge-green",
|
||||
CANCELADO: "badge-red",
|
||||
PENDIENTE: "badge-yellow",
|
||||
EN_COTIZACION: "badge-blue",
|
||||
COTIZADO: "badge-blue",
|
||||
ACEPTADO: "badge-green",
|
||||
EN_PROCESO: "badge-orange",
|
||||
COMPLETADO: "badge-green",
|
||||
CANCELADO: "badge-red",
|
||||
};
|
||||
|
||||
const STATUS_FLOW = ["PENDIENTE", "CONTACTADO", "COTIZADO", "APROBADO", "EN_PROCESO", "COMPLETADO", "CANCELADO"];
|
||||
const STATUS_FLOW = ["PENDIENTE", "EN_COTIZACION", "COTIZADO", "ACEPTADO", "EN_PROCESO", "COMPLETADO", "CANCELADO"];
|
||||
|
||||
export default function AdminB2BPage() {
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
|
||||
@@ -1,52 +1,211 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const SERVICES = [
|
||||
{
|
||||
icon: "🚢",
|
||||
title: "Contenedor completo (FCL)",
|
||||
desc: "Importación de un contenedor completo de 20' o 40'. Ideal para grandes volúmenes de mercancía homogénea.",
|
||||
details: ["Contenedor 20' (~26 m³)", "Contenedor 40' (~67 m³)", "Coordinación puerta a puerta", "Gestión aduanera DAI completa"],
|
||||
badge: "B2B",
|
||||
},
|
||||
{
|
||||
icon: "📦",
|
||||
title: "Carga consolidada (LCL)",
|
||||
desc: "Comparte el espacio de un contenedor con otros importadores. Paga solo por el m³ que usas.",
|
||||
details: ["Mínimo 1 CBM", "Frecuencia semanal NJ → Ecuador", "Consolidación en nuestra bodega NJ", "Trazabilidad en tiempo real"],
|
||||
badge: "Más flexible",
|
||||
},
|
||||
{
|
||||
icon: "🏗️",
|
||||
title: "Pallets y carga suelta",
|
||||
desc: "Equipos, maquinaria, repuestos industriales y artículos de gran volumen o peso.",
|
||||
details: ["Hasta 2,500 kg por pallet", "Flejado y embalaje en NJ", "Seguro de carga incluido", "INEN y permisos SENAE"],
|
||||
badge: "",
|
||||
},
|
||||
{
|
||||
icon: "🚗",
|
||||
title: "Vehículos y maquinaria",
|
||||
desc: "Importación de automóviles, maquinaria agrícola, equipos industriales y vehículos especiales.",
|
||||
details: ["RO-RO y contenedor flat rack", "Inspección pre-embarque", "Gestión de homologación SENAE", "Coordinación con INEN y ANT"],
|
||||
badge: "",
|
||||
},
|
||||
];
|
||||
|
||||
const SECTORS = [
|
||||
{ icon: "👗", label: "Textil y confección" },
|
||||
{ icon: "👟", label: "Calzado (INEN obligatorio)" },
|
||||
{ icon: "⚙️", label: "Maquinaria industrial" },
|
||||
{ icon: "🏠", label: "Muebles y decoración" },
|
||||
{ icon: "🍎", label: "Alimentos y bebidas" },
|
||||
{ icon: "💻", label: "Electrónica y tecnología" },
|
||||
{ icon: "🔧", label: "Repuestos automotrices" },
|
||||
{ icon: "🧴", label: "Cosméticos y cuidado personal" },
|
||||
];
|
||||
|
||||
const PROCESS = [
|
||||
{ n: 1, title: "Solicita cotización", desc: "Completa el formulario con el tipo de mercancía, volumen y ciudad de origen en EE.UU.", color: "var(--primary)" },
|
||||
{ n: 2, title: "Recibe propuesta", desc: "Nuestro equipo te envía una propuesta detallada con costos de flete, seguro, SENAE e INEN en 24–48 horas.", color: "var(--accent)" },
|
||||
{ n: 3, title: "Acepta y prepara la carga", desc: "Coordinas con tu proveedor en EE.UU. el despacho a nuestra bodega en New Jersey.", color: "var(--primary)" },
|
||||
{ n: 4, title: "Recibimos y verificamos", desc: "Inspeccionamos, pesamos, fotografiamos y consolidamos tu carga. Tramitamos permisos INEN y certificados.", color: "var(--accent)" },
|
||||
{ n: 5, title: "Despacho internacional", desc: "Embarcamos tu carga vía aérea (urgente) o marítima (económica) con tracking en tiempo real.", color: "var(--primary)" },
|
||||
{ n: 6, title: "Gestión aduanera Ecuador", desc: "Nuestros agentes autorizados SENAE tramitan la DAI completa. Sin sorpresas en aduana.", color: "var(--accent)" },
|
||||
{ n: 7, title: "Entrega en tu destino", desc: "Coordinamos la entrega en bodega o puerta a puerta en cualquier ciudad del Ecuador.", color: "var(--primary)" },
|
||||
];
|
||||
|
||||
export default function CargaPesadaPage() {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh" }}>
|
||||
<div style={{ minHeight: "100vh", background: "var(--gray-50)" }}>
|
||||
{/* Navbar */}
|
||||
<nav className="navbar">
|
||||
<div className="container navbar-inner">
|
||||
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
|
||||
<div className="navbar-links">
|
||||
<Link href="/como-funciona" className="navbar-link">Cómo funciona</Link>
|
||||
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
|
||||
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
|
||||
<Link href="/calculadora" className="navbar-link">Calculadora</Link>
|
||||
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
|
||||
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-sm">Cotizar B2B</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div style={{ paddingTop: "var(--nav-h)" }}>
|
||||
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
|
||||
<div className="container">
|
||||
<h1 style={{ fontSize: "clamp(1.75rem,4vw,2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>Carga Pesada y B2B</h1>
|
||||
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 600, margin: "0 auto" }}>
|
||||
Importaciones de volumen para empresas y emprendedores. Maquinaria, muebles, vehículos y más.
|
||||
|
||||
{/* Hero */}
|
||||
<div style={{ background: "linear-gradient(135deg, #0a0f2e 0%, #001F5B 60%, #0f3a8a 100%)", color: "white", padding: "5rem 0 4rem", position: "relative", overflow: "hidden" }}>
|
||||
<div style={{ position: "absolute", top: 0, right: 0, width: 400, height: 400, background: "radial-gradient(circle, rgba(255,107,0,.15) 0%, transparent 70%)", pointerEvents: "none" }} />
|
||||
<div className="container" style={{ position: "relative", zIndex: 1, textAlign: "center" }}>
|
||||
<div style={{ display: "inline-flex", gap: ".5rem", marginBottom: "1.5rem", flexWrap: "wrap", justifyContent: "center" }}>
|
||||
{["FCL / LCL", "Pallets", "Maquinaria", "SENAE Autorizado", "INEN"].map(b => (
|
||||
<span key={b} style={{ background: "rgba(255,107,0,.2)", color: "#FDB87A", fontSize: ".72rem", fontWeight: 700, padding: "4px 12px", borderRadius: 999 }}>{b}</span>
|
||||
))}
|
||||
</div>
|
||||
<h1 style={{ fontSize: "clamp(2rem,5vw,3rem)", fontWeight: 900, lineHeight: 1.15, marginBottom: "1.25rem" }}>
|
||||
Carga Pesada<br />
|
||||
<span style={{ color: "#FF6B00" }}>EE.UU. → Ecuador</span>
|
||||
</h1>
|
||||
<p style={{ color: "rgba(255,255,255,.75)", fontSize: "1.1rem", maxWidth: 580, margin: "0 auto 2.5rem", lineHeight: 1.7 }}>
|
||||
Importación de volumen para empresas y mayoristas. Contenedores, pallets, maquinaria industrial.
|
||||
Gestión aduanera DAI completa con agentes SENAE autorizados.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
|
||||
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-lg">
|
||||
Solicitar cotización →
|
||||
</Link>
|
||||
<Link href="/carga-pesada/como-funciona" className="btn btn-outline btn-lg" style={{ color: "white", borderColor: "rgba(255,255,255,.3)" }}>
|
||||
Ver proceso
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
{/* Servicios */}
|
||||
<div className="section" style={{ background: "white" }}>
|
||||
<div className="container">
|
||||
<div className="grid-3" style={{ marginBottom: "3rem" }}>
|
||||
{[
|
||||
{ icon: "🚢", title: "Carga marítima FCL/LCL", desc: "Contenedores completos o carga consolidada desde puertos de EE.UU. a Ecuador (Guayaquil / Manta)." },
|
||||
{ icon: "✈️", title: "Flete aéreo express", desc: "Para carga urgente o mercancía de alto valor. Tiempo de tránsito 3–5 días." },
|
||||
{ icon: "🏭", title: "Soluciones B2B", desc: "Gestión de importaciones recurrentes para empresas. Precios preferenciales por volumen." },
|
||||
].map(s => (
|
||||
<div key={s.title} className="card" style={{ padding: "2rem" }}>
|
||||
<div style={{ textAlign: "center", marginBottom: "3rem" }}>
|
||||
<div style={{ fontSize: ".85rem", fontWeight: 700, color: "var(--primary)", textTransform: "uppercase", letterSpacing: "2px", marginBottom: ".75rem" }}>Nuestros servicios</div>
|
||||
<h2 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 800, marginBottom: "1rem" }}>Soluciones para cada tipo de carga</h2>
|
||||
<p style={{ color: "var(--gray-500)", maxWidth: 560, margin: "0 auto" }}>
|
||||
Desde un pallet hasta un contenedor completo. Coordinamos cada detalle logístico y aduanero.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "1.5rem" }}>
|
||||
{SERVICES.map(s => (
|
||||
<div key={s.title} className="card" style={{ padding: "1.75rem", position: "relative" }}>
|
||||
{s.badge && (
|
||||
<span className="badge badge-blue" style={{ position: "absolute", top: "1rem", right: "1rem", fontSize: ".7rem" }}>{s.badge}</span>
|
||||
)}
|
||||
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>{s.icon}</div>
|
||||
<h3 style={{ fontWeight: 700, marginBottom: ".75rem" }}>{s.title}</h3>
|
||||
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{s.desc}</p>
|
||||
<h3 style={{ fontWeight: 700, fontSize: "1.05rem", marginBottom: ".6rem" }}>{s.title}</h3>
|
||||
<p style={{ color: "var(--gray-500)", fontSize: ".9rem", lineHeight: 1.6, marginBottom: "1rem" }}>{s.desc}</p>
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: ".35rem" }}>
|
||||
{s.details.map(d => (
|
||||
<li key={d} style={{ fontSize: ".82rem", color: "var(--gray-600)", display: "flex", gap: ".4rem", alignItems: "flex-start" }}>
|
||||
<span style={{ color: "var(--green)", marginTop: "2px", flexShrink: 0 }}>✓</span>
|
||||
{d}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>¿Necesitas importar en volumen?</h2>
|
||||
<p style={{ color: "var(--gray-500)", marginBottom: "2rem" }}>Completa el formulario y un asesor te contactará en menos de 24 horas.</p>
|
||||
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-xl">Solicitar cotización →</Link>
|
||||
{/* Proceso */}
|
||||
<div className="section-gray">
|
||||
<div className="container" style={{ maxWidth: 800 }}>
|
||||
<div style={{ textAlign: "center", marginBottom: "3rem" }}>
|
||||
<div style={{ fontSize: ".85rem", fontWeight: 700, color: "var(--primary)", textTransform: "uppercase", letterSpacing: "2px", marginBottom: ".75rem" }}>Proceso</div>
|
||||
<h2 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 800 }}>De NJ a Ecuador en 7 pasos</h2>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
|
||||
{PROCESS.map((p, i) => (
|
||||
<div key={p.n} style={{ display: "flex", gap: "1.25rem", alignItems: "flex-start" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", flexShrink: 0 }}>
|
||||
<div style={{ width: 44, height: 44, borderRadius: "50%", background: p.color, color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: "1rem" }}>{p.n}</div>
|
||||
{i < PROCESS.length - 1 && <div style={{ width: 2, height: 36, background: "var(--gray-200)", marginTop: ".25rem" }} />}
|
||||
</div>
|
||||
<div className="card" style={{ flex: 1, padding: "1rem 1.25rem" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: ".95rem", marginBottom: ".3rem" }}>{p.title}</div>
|
||||
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", lineHeight: 1.6 }}>{p.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sectores */}
|
||||
<div className="section" style={{ background: "white" }}>
|
||||
<div className="container">
|
||||
<div style={{ textAlign: "center", marginBottom: "2.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.75rem", fontWeight: 800, marginBottom: ".75rem" }}>Sectores que atendemos</h2>
|
||||
<p style={{ color: "var(--gray-500)" }}>Experiencia en los rubros con mayor volumen de importación Ecuador–EE.UU.</p>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "1rem" }}>
|
||||
{SECTORS.map(s => (
|
||||
<div key={s.label} className="card" style={{ padding: "1.25rem", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "2rem", marginBottom: ".5rem" }}>{s.icon}</div>
|
||||
<div style={{ fontSize: ".85rem", fontWeight: 600, color: "var(--gray-700)" }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* INEN callout */}
|
||||
<div className="section-gray">
|
||||
<div className="container" style={{ maxWidth: 720 }}>
|
||||
<div className="card" style={{ padding: "2rem", borderLeft: "4px solid var(--primary)" }}>
|
||||
<h3 style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: ".75rem" }}>
|
||||
📋 Certificaciones INEN — ¿Tu producto lo requiere?
|
||||
</h3>
|
||||
<p style={{ color: "var(--gray-600)", lineHeight: 1.7, marginBottom: "1rem" }}>
|
||||
Los productos regulados (calzado, textiles, electrónicos, alimentos, cosméticos) deben cumplir las normas técnicas del <strong>INEN</strong> y obtener el Registro de Conformidad antes del embarque.
|
||||
Nuestro equipo verifica y gestiona este proceso por ti.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: ".75rem", flexWrap: "wrap" }}>
|
||||
<Link href="/carga-pesada/inen" className="btn btn-outline btn-sm">Ver productos regulados</Link>
|
||||
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-sm">Consultar mi caso</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Final */}
|
||||
<div className="section" style={{ background: "linear-gradient(135deg, var(--primary) 0%, #0f3a8a 100%)", color: "white" }}>
|
||||
<div className="container" style={{ textAlign: "center" }}>
|
||||
<h2 style={{ fontSize: "clamp(1.5rem,3vw,2.25rem)", fontWeight: 800, marginBottom: "1rem" }}>¿Listo para importar?</h2>
|
||||
<p style={{ color: "rgba(255,255,255,.8)", maxWidth: 520, margin: "0 auto 2rem", fontSize: "1.05rem" }}>
|
||||
Obtén una cotización sin costo en menos de 48 horas. Nuestro equipo bilingüe NJ–Ecuador te acompaña en todo el proceso.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
|
||||
<Link href="/carga-pesada/cotizacion" className="btn btn-accent btn-xl">Solicitar cotización gratuita →</Link>
|
||||
<Link href="/quienes-somos" className="btn btn-outline btn-xl" style={{ color: "white", borderColor: "rgba(255,255,255,.4)" }}>Conocer el equipo</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,11 +19,15 @@ function PagoContent() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const packageId = params.get("packageId");
|
||||
const sessionId = params.get("session_id"); // Stripe success callback
|
||||
const stripeOk = params.get("success") === "1";
|
||||
const stripeCancelled = params.get("cancelled") === "1";
|
||||
const user = getUser();
|
||||
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
@@ -36,17 +40,36 @@ function PagoContent() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [packageId, router, user]);
|
||||
|
||||
// Auto-confirmar cuando Stripe redirige de vuelta con session_id
|
||||
useEffect(() => {
|
||||
if (!stripeOk || !sessionId || !data) return;
|
||||
setConfirming(true);
|
||||
api.payments.confirmSession(sessionId)
|
||||
.then(async () => {
|
||||
setSuccess(true);
|
||||
const fresh = await api.payments.packageDetail(packageId!);
|
||||
setData(fresh);
|
||||
})
|
||||
.catch(e => setError(e?.message ?? "Error al confirmar el pago."))
|
||||
.finally(() => setConfirming(false));
|
||||
}, [stripeOk, sessionId, data]);
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!data) return;
|
||||
setPaying(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 1. Crear intent de pago
|
||||
const intent = await api.payments.createIntent(data.package.id);
|
||||
// 2. En producción aquí se abre Stripe Checkout/PayPhone; en dev confirmamos directamente
|
||||
|
||||
// Si Stripe está configurado, redirigir al checkout hospedado
|
||||
if (intent.checkoutUrl) {
|
||||
window.location.href = intent.checkoutUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback dev: confirmar directamente (stub sin Stripe)
|
||||
await api.payments.confirm(intent.id);
|
||||
setSuccess(true);
|
||||
// Recargar datos
|
||||
const fresh = await api.payments.packageDetail(packageId!);
|
||||
setData(fresh);
|
||||
} catch (e: any) {
|
||||
@@ -56,10 +79,11 @@ function PagoContent() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (loading || confirming) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "60vh" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", minHeight: "60vh", gap: "1rem" }}>
|
||||
<div className="spinner" />
|
||||
{confirming && <p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>Verificando pago con Stripe...</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -144,6 +168,13 @@ function PagoContent() {
|
||||
<strong>Valor declarado:</strong> ${pkg?.declaredValue} USD
|
||||
</div>
|
||||
|
||||
{/* Alerta de cancelación de Stripe */}
|
||||
{stripeCancelled && !success && (
|
||||
<div style={{ background: "#FFF7ED", border: "1px solid #FED7AA", borderRadius: 8, padding: "1rem", marginBottom: "1.5rem", fontSize: ".875rem", color: "#9A3412" }}>
|
||||
Cancelaste el proceso de pago. Puedes intentarlo de nuevo cuando quieras.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alerta de éxito */}
|
||||
{(success || alreadyPaid) && (
|
||||
<div style={{ background: "#F0FDF4", border: "1px solid #86EFAC", borderRadius: 8, padding: "1rem", marginBottom: "1.5rem", display: "flex", gap: ".75rem", alignItems: "flex-start" }}>
|
||||
@@ -170,7 +201,7 @@ function PagoContent() {
|
||||
onClick={handlePay}
|
||||
disabled={paying}
|
||||
>
|
||||
{paying ? "Procesando pago..." : `Pagar $${breakdown?.total ?? 0} USD`}
|
||||
{paying ? "Redirigiendo a Stripe..." : `Pagar $${breakdown?.total ?? 0} USD con Stripe`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,109 +1,298 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, getUser, setUser } from "@/lib/api";
|
||||
import { Timestamp } from "@/app/_components/timestamp";
|
||||
|
||||
type Section = "perfil" | "password" | "mfa";
|
||||
|
||||
export default function PerfilPage() {
|
||||
const [user, setUserState] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mfaSetup, setMfaSetup] = useState<any>(null);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [mfaMsg, setMfaMsg] = useState("");
|
||||
const [mfaError, setMfaError] = useState("");
|
||||
const [user, setUserState] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [section, setSection] = useState<Section>("perfil");
|
||||
|
||||
// ── Edit profile ──────────────────────────────────────────
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ firstName: "", lastName: "", phone: "" });
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
const [editMsg, setEditMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// ── Change password ───────────────────────────────────────
|
||||
const [pwForm, setPwForm] = useState({ oldPassword: "", newPassword: "", confirm: "" });
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
const [pwMsg, setPwMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// ── MFA ───────────────────────────────────────────────────
|
||||
const [mfaSetup, setMfaSetup] = useState<any>(null);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [mfaMsg, setMfaMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.auth.me().then(me => { setUserState(me); setUser(me); }).catch(() => {}).finally(() => setLoading(false));
|
||||
api.auth.me()
|
||||
.then(me => { setUserState(me); setUser(me); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSetupMfa = async () => {
|
||||
try {
|
||||
const data = await api.auth.setupMfa();
|
||||
setMfaSetup(data);
|
||||
} catch (err: any) { setMfaError(err.message); }
|
||||
const reloadUser = async () => {
|
||||
const me = await api.auth.me();
|
||||
setUserState(me); setUser(me);
|
||||
};
|
||||
|
||||
// ── Profile edit ──────────────────────────────────────────
|
||||
const startEdit = () => {
|
||||
setEditForm({ firstName: user.firstName, lastName: user.lastName, phone: user.phone ?? "" });
|
||||
setEditMode(true); setEditMsg(null);
|
||||
};
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setEditMsg(null); setEditSaving(true);
|
||||
try {
|
||||
await api.auth.updateProfile(editForm);
|
||||
await reloadUser();
|
||||
setEditMode(false);
|
||||
setEditMsg({ type: "success", text: "Perfil actualizado." });
|
||||
} catch (err: any) {
|
||||
setEditMsg({ type: "error", text: err.message ?? "Error al guardar." });
|
||||
} finally { setEditSaving(false); }
|
||||
};
|
||||
|
||||
// ── Password change ───────────────────────────────────────
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setPwMsg(null);
|
||||
if (pwForm.newPassword !== pwForm.confirm) {
|
||||
setPwMsg({ type: "error", text: "Las contraseñas nuevas no coinciden." }); return;
|
||||
}
|
||||
if (pwForm.newPassword.length < 8) {
|
||||
setPwMsg({ type: "error", text: "La nueva contraseña debe tener al menos 8 caracteres." }); return;
|
||||
}
|
||||
setPwSaving(true);
|
||||
try {
|
||||
await api.auth.changePassword({ oldPassword: pwForm.oldPassword, newPassword: pwForm.newPassword });
|
||||
setPwMsg({ type: "success", text: "Contraseña cambiada correctamente." });
|
||||
setPwForm({ oldPassword: "", newPassword: "", confirm: "" });
|
||||
} catch (err: any) {
|
||||
setPwMsg({ type: "error", text: err.message ?? "Error al cambiar contraseña." });
|
||||
} finally { setPwSaving(false); }
|
||||
};
|
||||
|
||||
// ── MFA setup ─────────────────────────────────────────────
|
||||
const handleSetupMfa = async () => {
|
||||
try { const data = await api.auth.setupMfa(); setMfaSetup(data); setMfaMsg(null); }
|
||||
catch (err: any) { setMfaMsg({ type: "error", text: err.message }); }
|
||||
};
|
||||
const handleVerifyMfa = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setMfaError(""); setMfaMsg("");
|
||||
e.preventDefault(); setMfaMsg(null);
|
||||
try {
|
||||
await api.auth.verifyMfa(totpCode);
|
||||
setMfaMsg("MFA activado correctamente.");
|
||||
setMfaMsg({ type: "success", text: "MFA activado correctamente." });
|
||||
setMfaSetup(null); setTotpCode("");
|
||||
const me = await api.auth.me();
|
||||
setUserState(me); setUser(me);
|
||||
} catch (err: any) { setMfaError(err.message ?? "Código inválido"); }
|
||||
await reloadUser();
|
||||
} catch (err: any) { setMfaMsg({ type: "error", text: err.message ?? "Código inválido" }); }
|
||||
};
|
||||
const handleDisableMfa = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setMfaMsg(null);
|
||||
try {
|
||||
await api.auth.disableMfa(totpCode);
|
||||
setMfaMsg({ type: "success", text: "MFA desactivado." });
|
||||
setTotpCode("");
|
||||
await reloadUser();
|
||||
} catch (err: any) { setMfaMsg({ type: "error", text: err.message ?? "Código inválido" }); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
|
||||
if (loading) return <div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}><div className="spinner" /></div>;
|
||||
|
||||
const TABS: Array<{ id: Section; label: string; icon: string }> = [
|
||||
{ id: "perfil", label: "Datos personales", icon: "👤" },
|
||||
{ id: "password", label: "Contraseña", icon: "🔑" },
|
||||
{ id: "mfa", label: "Autenticación MFA", icon: "🔐" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="dash-page-title">Mi Perfil</h1>
|
||||
<p className="dash-page-subtitle">Información de tu cuenta.</p>
|
||||
<p className="dash-page-subtitle">Gestiona tu cuenta y seguridad.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="font-semibold">Datos personales</span></div>
|
||||
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
|
||||
{[
|
||||
["Nombre", `${user.firstName} ${user.lastName}`],
|
||||
["Email", user.email],
|
||||
["Teléfono", user.phone ?? "—"],
|
||||
["Rol", user.role],
|
||||
["Casillero", user.suite?.code ?? "—"],
|
||||
["Miembro desde", new Date(user.createdAt).toLocaleDateString("es-EC")],
|
||||
["Último acceso", user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString("es-EC") : "—"],
|
||||
].map(([k, v]) => (
|
||||
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
|
||||
<span style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{k}</span>
|
||||
<span style={{ fontWeight: 500, fontSize: ".9rem" }}>{v as string}</span>
|
||||
{/* Tabs */}
|
||||
<div style={{ display: "flex", gap: ".5rem", marginBottom: "1.5rem", borderBottom: "2px solid var(--gray-100)", paddingBottom: ".5rem", flexWrap: "wrap" }}>
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setSection(t.id)}
|
||||
style={{
|
||||
padding: ".5rem 1rem",
|
||||
borderRadius: "var(--radius)",
|
||||
fontWeight: section === t.id ? 700 : 500,
|
||||
fontSize: ".875rem",
|
||||
background: section === t.id ? "var(--primary)" : "transparent",
|
||||
color: section === t.id ? "white" : "var(--gray-600)",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
transition: "all .15s",
|
||||
}}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Datos personales ── */}
|
||||
{section === "perfil" && (
|
||||
<div className="card" style={{ maxWidth: 560 }}>
|
||||
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 700 }}>Datos personales</span>
|
||||
{!editMode && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={startEdit}>Editar</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{editMsg && <div className={`alert alert-${editMsg.type} mb-4`}>{editMsg.text}</div>}
|
||||
|
||||
{!editMode ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
|
||||
{[
|
||||
["Nombre", `${user.firstName} ${user.lastName}`],
|
||||
["Email", user.email],
|
||||
["Teléfono", user.phone ?? "—"],
|
||||
["Rol", user.role],
|
||||
["Casillero", user.suite?.code ?? "—"],
|
||||
["Miembro desde", null],
|
||||
["Último acceso", null],
|
||||
].map(([k, v], i) => (
|
||||
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
|
||||
<span style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{k}</span>
|
||||
<span style={{ fontWeight: 500, fontSize: ".9rem" }}>
|
||||
{i === 5 ? <Timestamp value={user.createdAt} dateOnly /> :
|
||||
i === 6 ? (user.lastLoginAt ? <Timestamp value={user.lastLoginAt} /> : "—") :
|
||||
v as string}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<form onSubmit={handleSaveProfile} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div>
|
||||
<label className="form-label">Nombre</label>
|
||||
<input className="form-input" value={editForm.firstName}
|
||||
onChange={e => setEditForm(f => ({ ...f, firstName: e.target.value }))} required minLength={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Apellido</label>
|
||||
<input className="form-input" value={editForm.lastName}
|
||||
onChange={e => setEditForm(f => ({ ...f, lastName: e.target.value }))} required minLength={2} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Teléfono (con código de país)</label>
|
||||
<input className="form-input" placeholder="+593 99 123 4567" value={editForm.phone}
|
||||
onChange={e => setEditForm(f => ({ ...f, phone: e.target.value }))} />
|
||||
<p style={{ fontSize: ".75rem", color: "var(--gray-500)", marginTop: ".25rem" }}>
|
||||
Incluye código de país para activar notificaciones WhatsApp (ej: +593912345678)
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: ".75rem", justifyContent: "flex-end" }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setEditMode(false)} disabled={editSaving}>Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={editSaving}>
|
||||
{editSaving ? "Guardando..." : "Guardar cambios"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="font-semibold">Seguridad</span></div>
|
||||
{/* ── Contraseña ── */}
|
||||
{section === "password" && (
|
||||
<div className="card" style={{ maxWidth: 460 }}>
|
||||
<div className="card-header"><span style={{ fontWeight: 700 }}>Cambiar contraseña</span></div>
|
||||
<div className="card-body">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
|
||||
{pwMsg && <div className={`alert alert-${pwMsg.type} mb-4`}>{pwMsg.text}</div>}
|
||||
<form onSubmit={handleChangePassword} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>Autenticación en dos pasos (MFA)</div>
|
||||
<div style={{ fontSize: ".85rem", color: "var(--gray-500)", marginTop: ".2rem" }}>
|
||||
{user.mfaEnabled ? "Activada — tu cuenta tiene protección extra." : "No activada — te recomendamos habilitarla."}
|
||||
</div>
|
||||
<label className="form-label">Contraseña actual</label>
|
||||
<input type="password" className="form-input" value={pwForm.oldPassword}
|
||||
onChange={e => setPwForm(f => ({ ...f, oldPassword: e.target.value }))} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Nueva contraseña</label>
|
||||
<input type="password" className="form-input" value={pwForm.newPassword}
|
||||
onChange={e => setPwForm(f => ({ ...f, newPassword: e.target.value }))} required minLength={8} autoComplete="new-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Confirmar nueva contraseña</label>
|
||||
<input type="password" className="form-input" value={pwForm.confirm}
|
||||
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} required minLength={8} autoComplete="new-password" />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={pwSaving}>
|
||||
{pwSaving ? "Cambiando..." : "Cambiar contraseña"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── MFA ── */}
|
||||
{section === "mfa" && (
|
||||
<div className="card" style={{ maxWidth: 520 }}>
|
||||
<div className="card-header">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 700 }}>Autenticación en dos pasos (TOTP)</span>
|
||||
<span className={`badge ${user.mfaEnabled ? "badge-green" : "badge-gray"}`}>
|
||||
{user.mfaEnabled ? "Activada" : "Desactivada"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{mfaMsg && <div className={`alert alert-${mfaMsg.type}`}>{mfaMsg.text}</div>}
|
||||
|
||||
{mfaMsg && <div className="alert alert-success mb-4">{mfaMsg}</div>}
|
||||
{mfaError && <div className="alert alert-error mb-4">{mfaError}</div>}
|
||||
|
||||
{/* Activar MFA */}
|
||||
{!user.mfaEnabled && !mfaSetup && (
|
||||
<button className="btn btn-outline" onClick={handleSetupMfa}>Activar MFA</button>
|
||||
<div>
|
||||
<p style={{ fontSize: ".9rem", color: "var(--gray-600)", marginBottom: "1rem" }}>
|
||||
La autenticación en dos pasos agrega una capa extra de seguridad. Necesitas una app TOTP como Google Authenticator o Authy.
|
||||
</p>
|
||||
<button className="btn btn-primary" onClick={handleSetupMfa}>Activar MFA</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mfaSetup && (
|
||||
{!user.mfaEnabled && mfaSetup && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div className="alert alert-info">
|
||||
<strong>1.</strong> Escanea este QR con Google Authenticator, Authy, u otra app TOTP.<br />
|
||||
<strong>2.</strong> Ingresa el código de 6 dígitos para confirmar.
|
||||
<strong>Paso 1:</strong> Escanea el QR con tu app TOTP o copia el secreto manualmente.<br />
|
||||
<strong>Paso 2:</strong> Ingresa el código de 6 dígitos para confirmar.
|
||||
</div>
|
||||
<div style={{ background: "var(--gray-100)", borderRadius: "var(--radius)", padding: "1rem", wordBreak: "break-all", fontSize: ".8rem", fontFamily: "monospace" }}>
|
||||
<div style={{ background: "var(--gray-50)", borderRadius: "var(--radius)", padding: "1rem", fontSize: ".8rem", fontFamily: "monospace", wordBreak: "break-all", color: "var(--gray-700)" }}>
|
||||
{mfaSetup.otpAuthUrl}
|
||||
</div>
|
||||
<p style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>Secret: <code>{mfaSetup.secret}</code></p>
|
||||
<p style={{ fontSize: ".8rem", color: "var(--gray-500)", margin: 0 }}>
|
||||
Secreto manual: <code style={{ background: "var(--gray-100)", padding: "2px 6px", borderRadius: 4 }}>{mfaSetup.secret}</code>
|
||||
</p>
|
||||
<form onSubmit={handleVerifyMfa} style={{ display: "flex", gap: ".75rem" }}>
|
||||
<input className="input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
|
||||
<input className="form-input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value)} maxLength={6} pattern="\d{6}" required />
|
||||
<button type="submit" className="btn btn-primary">Verificar</button>
|
||||
<button type="submit" className="btn btn-primary">Verificar y activar</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desactivar MFA */}
|
||||
{user.mfaEnabled && (
|
||||
<div>
|
||||
<div className="alert alert-warning" style={{ marginBottom: "1rem" }}>
|
||||
Para desactivar MFA debes confirmar con un código de tu app TOTP.
|
||||
</div>
|
||||
<form onSubmit={handleDisableMfa} style={{ display: "flex", gap: ".75rem" }}>
|
||||
<input className="form-input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value)} maxLength={6} pattern="\d{6}" required />
|
||||
<button type="submit" className="btn btn-outline" style={{ borderColor: "var(--error)", color: "var(--error)" }}>
|
||||
Desactivar MFA
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,9 +85,14 @@ export const api = {
|
||||
register: (body: any) => request<any>("/auth/register", { method: "POST", body: JSON.stringify(body) }),
|
||||
login: (body: any) => request<any>("/auth/login", { method: "POST", body: JSON.stringify(body) }),
|
||||
me: () => request<any>("/auth/me"),
|
||||
updateProfile: (body: { firstName?: string; lastName?: string; phone?: string }) =>
|
||||
request<any>("/auth/me", { method: "PATCH", body: JSON.stringify(body) }),
|
||||
changePassword: (body: { oldPassword: string; newPassword: string }) =>
|
||||
request<any>("/auth/password", { method: "PATCH", body: JSON.stringify(body) }),
|
||||
logout: (refreshToken: string) => request<any>("/auth/logout", { method: "POST", body: JSON.stringify({ refreshToken }) }),
|
||||
setupMfa: () => request<any>("/auth/mfa/setup", { method: "POST" }),
|
||||
verifyMfa:(totpCode: string) => request<any>("/auth/mfa/verify", { method: "POST", body: JSON.stringify({ totpCode }) }),
|
||||
setupMfa: () => request<any>("/auth/mfa/setup", { method: "POST" }),
|
||||
verifyMfa: (totpCode: string) => request<any>("/auth/mfa/verify", { method: "POST", body: JSON.stringify({ totpCode }) }),
|
||||
disableMfa: (totpCode: string) => request<any>("/auth/mfa/disable", { method: "POST", body: JSON.stringify({ totpCode }) }),
|
||||
},
|
||||
packages: {
|
||||
list: (params?: Record<string,string>) => request<any[]>("/packages" + (params ? "?" + new URLSearchParams(params) : "")),
|
||||
@@ -168,6 +173,8 @@ export const api = {
|
||||
request<any>("/payments/intent", { method: "POST", body: JSON.stringify({ packageId, provider }) }),
|
||||
confirm: (paymentId: string) =>
|
||||
request<any>(`/payments/${paymentId}/confirm`, { method: "POST" }),
|
||||
confirmSession: (sessionId: string) =>
|
||||
request<any>("/payments/confirm-session", { method: "POST", body: JSON.stringify({ sessionId }) }),
|
||||
},
|
||||
consolidations: {
|
||||
list: () => request<any[]>("/consolidations"),
|
||||
|
||||
Generated
+16
@@ -71,6 +71,9 @@ importers:
|
||||
rxjs:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2
|
||||
stripe:
|
||||
specifier: ^22.2.0
|
||||
version: 22.2.0(@types/node@22.19.19)
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^11.0.7
|
||||
@@ -2889,6 +2892,15 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
stripe@22.2.0:
|
||||
resolution: {integrity: sha512-WFGpMOom9QZqso1kcnSwJsCdC1QHDlMoCOxBZRf3JraMzhkfw7dgSdD2a1CFZrqC+mzAfqeEtYILrZhWKIDruA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
strtok3@10.3.5:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6230,6 +6242,10 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
stripe@22.2.0(@types/node@22.19.19):
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.19
|
||||
|
||||
strtok3@10.3.5:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
|
||||
Reference in New Issue
Block a user