feat: audit & sync with documentacion.html v1.1 — schema, tests, modules, web

Schema (packages/database/prisma/schema.prisma):
- Add SenaeCategory, B2BRequestStatus, NotificationChannel enums
- Add Package fields: lengthCm, widthCm, heightCm, hasDiscrepancy, photos[],
  senaeCategory, senaeAuthNumber, senaeDeclarationId
- Add userId field to PreAlert
- Add RefreshToken model (JWT auth — Fase 1)
- Add Tariff model (configurable rates: pricePerLb, insurancePct, etc.)
- Add B2BRequest model (heavy cargo imports — doc §13)
- Add Notification model (multi-channel: email, whatsapp, sms, push — doc §16)
- Add userAgent to AuditLog

API (apps/api):
- Add CalculatorModule: calculates SENAE cost breakdown (flete, seguro,
  FODINFA, arancel, IVA) per formulas in doc §15
- Add TrackingModule: public GET /api/tracking/:id endpoint (doc §07)
- Register both modules in AppModule
- Add Jest config + test scripts (test, test:watch, test:cov, test:ci)

Tests (80 tests, 97.45% coverage):
- calculator.util.spec.ts: 35 tests — SENAE formulas, volumetric weight,
  4x4 regime, tariff rates, error validation, breakdown
- tracking-id.util.spec.ts: 13 tests — format, uniqueness, validation
- suite-code.util.spec.ts: 13 tests — generation, parsing, address builder
- health.controller.spec.ts: 5 tests — ok/degraded/timestamp
- calculator.spec.ts: 8 tests — controller + service unit tests
- tracking.controller.spec.ts: 9 tests — public search, 404, no sensitive data

Web (apps/web):
- page.tsx: Full landing page matching documentation (Hero, Services,
  8 Modules, Tracking states, SENAE calculator preview, 6 Roles, Companies)
- /calculadora: SENAE categories + API usage guide
- /tracking: Tracking ID format + 11 states reference

Seed (packages/database/prisma/seed.ts):
- Creates all 6 roles (SUPER_ADMIN, ADMIN_EMPRESA, OPERADOR_BODEGA,
  AGENTE_ADUANERO, CLIENTE, SOPORTE)
- Assigns Suite EC-00001 to demo client
- Creates demo Package EC-20260506-000001 with status history
- Creates demo PreAlert and B2BRequest

.env.example: Added all 9 integrations from doc §18:
  Amazon SP-API, SENAE WebService, Stripe/PayPhone, WhatsApp Business API,
  SendGrid/SES, SMS/Twilio, FedEx/DHL/UPS couriers, S3/MinIO, Sentry

migration.sql: Updated to reflect all new models and fields
turbo.json + package.json: Added test, test:cov, test:ci tasks + db:seed script
This commit is contained in:
Lizandro Guarnizo
2026-06-01 08:25:01 -05:00
parent 094ea9cf81
commit 93db76897d
26 changed files with 4651 additions and 201 deletions
+28 -1
View File
@@ -7,7 +7,11 @@
"dev": "nest start --watch",
"start": "node dist/main",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:ci": "jest --ci --coverage --forceExit"
},
"dependencies": {
"@moraworld/database": "workspace:*",
@@ -21,8 +25,31 @@
"devDependencies": {
"@nestjs/cli": "^11.0.7",
"@nestjs/schematics": "^11.0.5",
"@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.21",
"jest": "^29.7.0",
"ts-jest": "^29.3.4",
"typescript": "^5.8.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s", "!**/*.module.ts", "!**/main.ts"],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
+4
View File
@@ -2,6 +2,8 @@ import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { HealthModule } from "./health/health.module";
import { PrismaModule } from "./prisma/prisma.module";
import { CalculatorModule } from "./calculator/calculator.module";
import { TrackingModule } from "./tracking/tracking.module";
@Module({
imports: [
@@ -11,6 +13,8 @@ import { PrismaModule } from "./prisma/prisma.module";
}),
PrismaModule,
HealthModule,
CalculatorModule,
TrackingModule,
],
})
export class AppModule {}
@@ -0,0 +1,62 @@
import { Controller, Get, Query, BadRequestException } from "@nestjs/common";
import { CalculatorService } from "./calculator.service";
import { SenaeCategory } from "../common/utils/calculator.util";
@Controller("calculator")
export class CalculatorController {
constructor(private readonly calculatorService: CalculatorService) {}
/**
* GET /api/calculator
* Calcula el costo de envío. Disponible sin autenticación (doc §15).
*
* Query params:
* value - Valor declarado en USD (requerido)
* weight - Peso en libras (requerido)
* length - Largo en cm (opcional, para peso volumétrico)
* width - Ancho en cm
* height - Alto en cm
* category - REGIMEN_4X4 | CATEGORIA_B | CATEGORIA_C | CATEGORIA_D (opcional)
* shipments - Envíos ya realizados este año (opcional, para 4×4)
*/
@Get()
calculate(
@Query("value") value: string,
@Query("weight") weight: string,
@Query("length") length?: string,
@Query("width") width?: string,
@Query("height") height?: string,
@Query("category") category?: string,
@Query("shipments") shipments?: string,
) {
const declaredValueUsd = parseFloat(value);
const weightLbs = parseFloat(weight);
if (isNaN(declaredValueUsd) || declaredValueUsd < 0) {
throw new BadRequestException("El parámetro 'value' debe ser un número >= 0.");
}
if (isNaN(weightLbs) || weightLbs <= 0) {
throw new BadRequestException("El parámetro 'weight' debe ser un número > 0.");
}
let parsedCategory: SenaeCategory | undefined;
if (category) {
if (!Object.values(SenaeCategory).includes(category as SenaeCategory)) {
throw new BadRequestException(
`Categoría inválida. Valores válidos: ${Object.values(SenaeCategory).join(", ")}`,
);
}
parsedCategory = category as SenaeCategory;
}
return this.calculatorService.calculate({
declaredValueUsd,
weightLbs,
lengthCm: length ? parseFloat(length) : undefined,
widthCm: width ? parseFloat(width) : undefined,
heightCm: height ? parseFloat(height) : undefined,
category: parsedCategory,
shipmentsThisYear: shipments ? parseInt(shipments, 10) : 0,
});
}
}
@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { CalculatorController } from "./calculator.controller";
import { CalculatorService } from "./calculator.service";
@Module({
controllers: [CalculatorController],
providers: [CalculatorService],
exports: [CalculatorService],
})
export class CalculatorModule {}
@@ -0,0 +1,59 @@
import { Injectable } from "@nestjs/common";
import {
CalculatorInput,
CalculatorResult,
SenaeCategory,
calculateShipping,
qualifiesFor4x4,
} from "../common/utils/calculator.util";
export interface CalcRequest {
declaredValueUsd: number;
weightLbs: number;
lengthCm?: number;
widthCm?: number;
heightCm?: number;
category?: SenaeCategory;
shipmentsThisYear?: number;
}
@Injectable()
export class CalculatorService {
/**
* Calcula el costo de envío con desglose SENAE.
* Si no se especifica categoría, determina automáticamente si aplica 4×4.
*/
calculate(req: CalcRequest): CalculatorResult {
const category = req.category ?? this.autoCategory(
req.declaredValueUsd,
req.weightLbs,
req.shipmentsThisYear ?? 0,
);
const input: CalculatorInput = {
declaredValueUsd: req.declaredValueUsd,
weightLbs: req.weightLbs,
lengthCm: req.lengthCm,
widthCm: req.widthCm,
heightCm: req.heightCm,
category,
};
return calculateShipping(input);
}
/**
* Determina la categoría mínima según los límites 4×4.
* Si no aplica, retorna CATEGORIA_B como base (el usuario puede cambiarla).
*/
autoCategory(
valueUsd: number,
weightLbs: number,
shipmentsThisYear: number,
): SenaeCategory {
if (qualifiesFor4x4(valueUsd, weightLbs, shipmentsThisYear)) {
return SenaeCategory.REGIMEN_4X4;
}
return SenaeCategory.CATEGORIA_B;
}
}
@@ -0,0 +1,86 @@
import { Test, TestingModule } from "@nestjs/testing";
import { CalculatorController } from "./calculator.controller";
import { CalculatorService } from "./calculator.service";
import { SenaeCategory } from "../common/utils/calculator.util";
import { BadRequestException } from "@nestjs/common";
describe("CalculatorController", () => {
let controller: CalculatorController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CalculatorController],
providers: [CalculatorService],
}).compile();
controller = module.get<CalculatorController>(CalculatorController);
});
it("debe estar definido", () => {
expect(controller).toBeDefined();
});
it("calcula correctamente con parámetros mínimos", () => {
const result = controller.calculate("150", "2");
expect(result.total).toBeGreaterThan(0);
expect(result.flete).toBe(7); // 2 × 3.50
});
it("acepta categoría explícita", () => {
const result = controller.calculate("500", "5", undefined, undefined, undefined, SenaeCategory.CATEGORIA_C);
expect(result.category).toBe(SenaeCategory.CATEGORIA_C);
expect(result.arancelRate).toBe(0.20);
});
it("lanza BadRequestException si value no es número", () => {
expect(() => controller.calculate("abc", "2")).toThrow(BadRequestException);
});
it("lanza BadRequestException si weight es 0", () => {
expect(() => controller.calculate("100", "0")).toThrow(BadRequestException);
});
it("lanza BadRequestException si value es negativo", () => {
expect(() => controller.calculate("-1", "2")).toThrow(BadRequestException);
});
it("lanza BadRequestException con categoría inválida", () => {
expect(() =>
controller.calculate("100", "2", undefined, undefined, undefined, "CATEGORIA_Z"),
).toThrow(BadRequestException);
});
it("calcula el peso volumétrico cuando se proveen dimensiones", () => {
const result = controller.calculate("100", "1", "50", "40", "30");
// 50×40×30 / 139 ≈ 43.17 lbs → mayor que 1 lb
expect(result.finalWeightLbs).toBeGreaterThan(1);
expect(result.volumetricWeightLbs).not.toBeNull();
});
});
describe("CalculatorService", () => {
let service: CalculatorService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CalculatorService],
}).compile();
service = module.get<CalculatorService>(CalculatorService);
});
it("autoCategory retorna REGIMEN_4X4 si califica", () => {
const cat = service.autoCategory(200, 2, 0);
expect(cat).toBe(SenaeCategory.REGIMEN_4X4);
});
it("autoCategory retorna CATEGORIA_B si no califica 4×4", () => {
const cat = service.autoCategory(500, 10, 0);
expect(cat).toBe(SenaeCategory.CATEGORIA_B);
});
it("calculate sin categoría asigna automáticamente", () => {
const result = service.calculate({ declaredValueUsd: 200, weightLbs: 2 });
expect(result.category).toBe(SenaeCategory.REGIMEN_4X4);
});
});
@@ -0,0 +1,288 @@
import {
calculateShipping,
calcVolumetricWeight,
qualifiesFor4x4,
SenaeCategory,
SENAE_TARIFF_RATES,
} from "./calculator.util";
/**
* Tests del servicio de calculadora SENAE.
* Validan exactamente las fórmulas documentadas en §15 de documentacion.html
*/
describe("Calculator — Fórmulas SENAE (doc §15)", () => {
// ─── Peso volumétrico ─────────────────────────────────────
describe("calcVolumetricWeight", () => {
it("calcula peso volumétrico correctamente: (L×A×H)/139", () => {
// 30 × 20 × 15 = 9000 / 139 ≈ 64.748 g → 0.0648 lbs (en lbs)
const result = calcVolumetricWeight(30, 20, 15);
expect(result).toBeCloseTo(9000 / 139, 3);
});
it("retorna null si faltan dimensiones", () => {
expect(calcVolumetricWeight(30, 20, undefined)).toBeNull();
expect(calcVolumetricWeight(undefined, undefined, undefined)).toBeNull();
});
it("retorna null si alguna dimensión es 0 o negativa", () => {
expect(calcVolumetricWeight(0, 20, 15)).toBeNull();
expect(calcVolumetricWeight(30, -5, 15)).toBeNull();
});
});
// ─── Régimen 4×4 ──────────────────────────────────────────
describe("qualifiesFor4x4", () => {
it("califica si valor ≤ $400 y peso ≤ 4 kg", () => {
// 2 lbs = 0.907 kg → bien dentro del límite
expect(qualifiesFor4x4(200, 2, 0)).toBe(true);
});
it("no califica si valor > $400", () => {
expect(qualifiesFor4x4(401, 2, 0)).toBe(false);
});
it("no califica si peso > 4 kg (≈ 8.82 lbs)", () => {
// 9 lbs = 4.08 kg
expect(qualifiesFor4x4(200, 9, 0)).toBe(false);
});
it("no califica si ya usó 4 envíos en el año", () => {
expect(qualifiesFor4x4(200, 2, 4)).toBe(false);
});
it("califica si aún tiene envíos disponibles (3 de 4)", () => {
expect(qualifiesFor4x4(200, 2, 3)).toBe(true);
});
it("califica exactamente en $400 y 4 kg (límite exacto)", () => {
const weightLbs = 4 / 0.453592; // exactamente 4 kg en lbs
expect(qualifiesFor4x4(400, weightLbs, 0)).toBe(true);
});
});
// ─── Tasas de arancel por categoría ───────────────────────
describe("SENAE_TARIFF_RATES", () => {
it("REGIMEN_4X4 tiene tasa 0%", () => {
expect(SENAE_TARIFF_RATES[SenaeCategory.REGIMEN_4X4]).toBe(0);
});
it("CATEGORIA_B tiene tasa 10%", () => {
expect(SENAE_TARIFF_RATES[SenaeCategory.CATEGORIA_B]).toBe(0.10);
});
it("CATEGORIA_C tiene tasa 20%", () => {
expect(SENAE_TARIFF_RATES[SenaeCategory.CATEGORIA_C]).toBe(0.20);
});
it("CATEGORIA_D tiene tasa 10% (base)", () => {
expect(SENAE_TARIFF_RATES[SenaeCategory.CATEGORIA_D]).toBe(0.10);
});
});
// ─── calculateShipping — Régimen 4×4 ──────────────────────
describe("calculateShipping — Régimen 4×4 (0% arancel)", () => {
const base = {
declaredValueUsd: 150,
weightLbs: 2,
category: SenaeCategory.REGIMEN_4X4,
};
it("flete = peso × $3.50", () => {
const r = calculateShipping(base);
expect(r.flete).toBe(2 * 3.5); // 7.00
});
it("seguro = valor × 2%", () => {
const r = calculateShipping(base);
expect(r.seguro).toBe(Math.round(150 * 0.02 * 100) / 100); // 3.00
});
it("FODINFA = valor × 0.5%", () => {
const r = calculateShipping(base);
expect(r.fodinfa).toBe(Math.round(150 * 0.005 * 100) / 100); // 0.75
});
it("arancel = 0 para régimen 4×4", () => {
const r = calculateShipping(base);
expect(r.arancel).toBe(0);
});
it("IVA = (valor + FODINFA + arancel) × 15%", () => {
const r = calculateShipping(base);
// IVA = (150 + 0.75 + 0) × 0.15 = 150.75 × 0.15 = 22.6125 → 22.61
const expected = Math.round((150 + 0.75 + 0) * 0.15 * 100) / 100;
expect(r.iva).toBe(expected);
});
it("TOTAL = Flete + Seguro + FODINFA + Arancel + IVA", () => {
const r = calculateShipping(base);
const expected = Math.round((r.flete + r.seguro + r.fodinfa + r.arancel + r.iva) * 100) / 100;
expect(r.total).toBe(expected);
});
it("is4x4Regime = true", () => {
const r = calculateShipping(base);
expect(r.is4x4Regime).toBe(true);
});
});
// ─── calculateShipping — Categoría B (10%) ────────────────
describe("calculateShipping — Categoría B (10% arancel)", () => {
const base = {
declaredValueUsd: 500,
weightLbs: 5,
category: SenaeCategory.CATEGORIA_B,
};
it("arancel = valor × 10%", () => {
const r = calculateShipping(base);
expect(r.arancel).toBe(Math.round(500 * 0.10 * 100) / 100); // 50.00
});
it("is4x4Regime = false", () => {
const r = calculateShipping(base);
expect(r.is4x4Regime).toBe(false);
});
it("total incluye todos los componentes", () => {
const r = calculateShipping(base);
expect(r.total).toBe(
Math.round((r.flete + r.seguro + r.fodinfa + r.arancel + r.iva) * 100) / 100,
);
});
});
// ─── calculateShipping — Categoría C (20%) ────────────────
describe("calculateShipping — Categoría C (20% arancel)", () => {
it("arancel = valor × 20%", () => {
const r = calculateShipping({
declaredValueUsd: 300,
weightLbs: 3,
category: SenaeCategory.CATEGORIA_C,
});
expect(r.arancel).toBe(Math.round(300 * 0.20 * 100) / 100); // 60.00
});
});
// ─── Peso volumétrico activo ───────────────────────────────
describe("calculateShipping — Peso volumétrico", () => {
it("usa el peso volumétrico si es mayor al real", () => {
// Caja grande liviana: 100 × 80 × 60 cm = 480000 / 139 ≈ 3453.2 lbs?
// No... 100×80×60 / 139 = 3453.2 → ese es el peso volumétrico en alguna unidad
// Pero el peso real es 1 lb → volumétrico es mucho mayor
const r = calculateShipping({
declaredValueUsd: 100,
weightLbs: 1,
lengthCm: 100,
widthCm: 80,
heightCm: 60,
category: SenaeCategory.REGIMEN_4X4,
});
// volumétrico = 100*80*60/139 = 3453.2...
expect(r.finalWeightLbs).toBeGreaterThan(1);
expect(r.volumetricWeightLbs).not.toBeNull();
});
it("usa el peso real si es mayor al volumétrico", () => {
// Caja pequeña pesada: 10×10×10 = 1000/139 ≈ 7.19 lbs, peso real = 20 lbs
const r = calculateShipping({
declaredValueUsd: 100,
weightLbs: 20,
lengthCm: 10,
widthCm: 10,
heightCm: 10,
category: SenaeCategory.CATEGORIA_B,
});
expect(r.finalWeightLbs).toBe(20);
});
it("sin dimensiones, finalWeightLbs = peso real", () => {
const r = calculateShipping({
declaredValueUsd: 100,
weightLbs: 5,
category: SenaeCategory.CATEGORIA_B,
});
expect(r.finalWeightLbs).toBe(5);
expect(r.volumetricWeightLbs).toBeNull();
});
});
// ─── Validaciones de entrada ──────────────────────────────
describe("calculateShipping — validaciones", () => {
it("lanza error si valor declarado es negativo", () => {
expect(() =>
calculateShipping({
declaredValueUsd: -1,
weightLbs: 2,
category: SenaeCategory.REGIMEN_4X4,
}),
).toThrow("El valor declarado no puede ser negativo.");
});
it("lanza error si el peso es 0 o negativo", () => {
expect(() =>
calculateShipping({
declaredValueUsd: 100,
weightLbs: 0,
category: SenaeCategory.REGIMEN_4X4,
}),
).toThrow("El peso debe ser mayor a 0.");
});
});
// ─── Breakdown ────────────────────────────────────────────
describe("breakdown", () => {
it("incluye 6 items (Flete, Seguro, FODINFA, Arancel, IVA, TOTAL)", () => {
const r = calculateShipping({
declaredValueUsd: 200,
weightLbs: 3,
category: SenaeCategory.CATEGORIA_B,
});
expect(r.breakdown.items).toHaveLength(6);
});
it("el último item (TOTAL) tiene isHighlighted=true", () => {
const r = calculateShipping({
declaredValueUsd: 200,
weightLbs: 3,
category: SenaeCategory.CATEGORIA_B,
});
const last = r.breakdown.items[r.breakdown.items.length - 1];
expect(last.isHighlighted).toBe(true);
expect(last.label).toBe("TOTAL");
});
});
// ─── Tarifa configurable ──────────────────────────────────
describe("tarifas configurables", () => {
it("acepta pricePerLb personalizado", () => {
const r = calculateShipping({
declaredValueUsd: 100,
weightLbs: 2,
category: SenaeCategory.REGIMEN_4X4,
pricePerLb: 4.0,
});
expect(r.flete).toBe(8.0);
});
it("acepta insurancePct personalizado", () => {
const r = calculateShipping({
declaredValueUsd: 100,
weightLbs: 2,
category: SenaeCategory.REGIMEN_4X4,
insurancePct: 0.03,
});
expect(r.seguro).toBe(3.0); // 100 × 3%
});
});
});
@@ -0,0 +1,231 @@
/**
* Calculadora SENAE — Lógica exacta de la documentación §15
*
* Categorías SENAE:
* REGIMEN_4X4 → 0% Hasta $400, 4 kg, 4 envíos/año — proceso simplificado
* CATEGORIA_B → 10% Bienes de consumo general
* CATEGORIA_C → 20% Textiles, calzado, artículos del hogar
* CATEGORIA_D → 0-15% Electrónicos — se usa 10% como base (puede variar)
*
* Fórmulas (doc §15):
* Peso_volumétrico = (largo_cm × ancho_cm × alto_cm) / 139
* Peso_final = MAX(peso_real_lbs, Peso_volumétrico)
* Flete = Peso_final × $3.50 (configurable)
* Seguro = valor_declarado × 0.02
* FODINFA = valor_declarado × 0.005
* Arancel = valor_declarado × % categoría
* IVA = (valor_declarado + FODINFA + Arancel) × 0.15
* TOTAL = Flete + Seguro + FODINFA + Arancel + IVA
*/
export enum SenaeCategory {
REGIMEN_4X4 = "REGIMEN_4X4",
CATEGORIA_B = "CATEGORIA_B",
CATEGORIA_C = "CATEGORIA_C",
CATEGORIA_D = "CATEGORIA_D",
}
export const SENAE_TARIFF_RATES: Record<SenaeCategory, number> = {
[SenaeCategory.REGIMEN_4X4]: 0,
[SenaeCategory.CATEGORIA_B]: 0.10,
[SenaeCategory.CATEGORIA_C]: 0.20,
[SenaeCategory.CATEGORIA_D]: 0.10, // base; puede ajustarse por subpartida
};
export interface CalculatorInput {
/** Valor declarado del producto en USD */
declaredValueUsd: number;
/** Peso real en libras */
weightLbs: number;
/** Largo en cm (para peso volumétrico, opcional) */
lengthCm?: number;
/** Ancho en cm */
widthCm?: number;
/** Alto en cm */
heightCm?: number;
/** Categoría SENAE del producto */
category: SenaeCategory;
/** Precio por libra en USD (default: 3.50) */
pricePerLb?: number;
/** Porcentaje de seguro (default: 0.02 = 2%) */
insurancePct?: number;
/** FODINFA (default: 0.005 = 0.5%) */
fodinfaPct?: number;
/** IVA (default: 0.15 = 15%) */
ivaPct?: number;
}
export interface CalculatorResult {
/** Peso volumétrico calculado en lbs */
volumetricWeightLbs: number | null;
/** Peso final usado para el cálculo (MAX entre real y volumétrico) */
finalWeightLbs: number;
/** Costo de flete */
flete: number;
/** Costo de seguro */
seguro: number;
/** FODINFA */
fodinfa: number;
/** Arancel */
arancel: number;
/** IVA */
iva: number;
/** Total a pagar */
total: number;
/** Categoría SENAE aplicada */
category: SenaeCategory;
/** Tasa de arancel aplicada (ej: 0.10 = 10%) */
arancelRate: number;
/** Si aplica el régimen 4×4 (sin arancel) */
is4x4Regime: boolean;
/** Desglose completo en formato display */
breakdown: CalculatorBreakdown;
}
export interface CalculatorBreakdown {
items: Array<{
label: string;
formula: string;
value: number;
isHighlighted?: boolean;
}>;
}
/**
* Calcula el peso volumétrico en libras.
* Fórmula: (largo × ancho × alto) / 139
* Si faltan dimensiones retorna null.
*/
export function calcVolumetricWeight(
lengthCm?: number,
widthCm?: number,
heightCm?: number,
): number | null {
if (!lengthCm || !widthCm || !heightCm) return null;
if (lengthCm <= 0 || widthCm <= 0 || heightCm <= 0) return null;
return (lengthCm * widthCm * heightCm) / 139;
}
/**
* Redondea a 2 decimales.
*/
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
/**
* Calcula el costo total de envío con desglose SENAE.
* Implementa exactamente las fórmulas del documento §15.
*/
export function calculateShipping(input: CalculatorInput): CalculatorResult {
const {
declaredValueUsd,
weightLbs,
lengthCm,
widthCm,
heightCm,
category,
pricePerLb = 3.5,
insurancePct = 0.02,
fodinfaPct = 0.005,
ivaPct = 0.15,
} = input;
if (declaredValueUsd < 0) throw new Error("El valor declarado no puede ser negativo.");
if (weightLbs <= 0) throw new Error("El peso debe ser mayor a 0.");
const arancelRate = SENAE_TARIFF_RATES[category];
const is4x4Regime = category === SenaeCategory.REGIMEN_4X4;
// Peso volumétrico
const volumetricWeightLbs = calcVolumetricWeight(lengthCm, widthCm, heightCm);
const finalWeightLbs = volumetricWeightLbs !== null
? Math.max(weightLbs, volumetricWeightLbs)
: weightLbs;
// Cálculos
const flete = round2(finalWeightLbs * pricePerLb);
const seguro = round2(declaredValueUsd * insurancePct);
const fodinfa = round2(declaredValueUsd * fodinfaPct);
const arancel = round2(declaredValueUsd * arancelRate);
const iva = round2((declaredValueUsd + fodinfa + arancel) * ivaPct);
const total = round2(flete + seguro + fodinfa + arancel + iva);
const breakdown: CalculatorBreakdown = {
items: [
{
label: "Flete",
formula: `${finalWeightLbs.toFixed(2)} lbs × $${pricePerLb.toFixed(2)}/lb`,
value: flete,
},
{
label: "Seguro (2%)",
formula: `$${declaredValueUsd.toFixed(2)} × ${(insurancePct * 100).toFixed(1)}%`,
value: seguro,
},
{
label: "FODINFA (0.5%)",
formula: `$${declaredValueUsd.toFixed(2)} × 0.5%`,
value: fodinfa,
},
{
label: `Arancel ${category.replace("_", " ")} (${(arancelRate * 100).toFixed(0)}%)`,
formula: `$${declaredValueUsd.toFixed(2)} × ${(arancelRate * 100).toFixed(0)}%`,
value: arancel,
},
{
label: "IVA (15%)",
formula: `($${declaredValueUsd.toFixed(2)} + FODINFA + Arancel) × 15%`,
value: iva,
},
{
label: "TOTAL",
formula: "Flete + Seguro + FODINFA + Arancel + IVA",
value: total,
isHighlighted: true,
},
],
};
return {
volumetricWeightLbs: volumetricWeightLbs !== null ? round2(volumetricWeightLbs) : null,
finalWeightLbs: round2(finalWeightLbs),
flete,
seguro,
fodinfa,
arancel,
iva,
total,
category,
arancelRate,
is4x4Regime,
breakdown,
};
}
/**
* Determina si un paquete aplica al régimen 4×4.
* Condiciones: valor ≤ $400, peso ≤ 4 kg. (doc §08 estado 06 / §15)
*
* @param valueUsd Valor declarado en USD
* @param weightLbs Peso en libras
* @param shipmentsThisYear Número de envíos ya realizados en el año calendario (default 0)
* @param max4x4Value Límite de valor (default $400)
* @param max4x4WeightKg Límite de peso en kg (default 4 kg)
* @param max4x4PerYear Máximo envíos/año (default 4)
*/
export function qualifiesFor4x4(
valueUsd: number,
weightLbs: number,
shipmentsThisYear = 0,
max4x4Value = 400,
max4x4WeightKg = 4,
max4x4PerYear = 4,
): boolean {
const weightKg = weightLbs * 0.453592;
return (
valueUsd <= max4x4Value &&
weightKg <= max4x4WeightKg &&
shipmentsThisYear < max4x4PerYear
);
}
@@ -0,0 +1,87 @@
import {
generateSuiteCode,
parseSuiteCode,
buildSuiteAddress,
} from "./suite-code.util";
/**
* Tests del generador de Suite (casillero).
* Formato documentado en §02 y §09: EC-XXXXX
* Dirección: "150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050"
*/
describe("SuiteCode Utils (doc §02 / §09)", () => {
describe("generateSuiteCode", () => {
it("genera el código EC-00001 para secuencia 1", () => {
expect(generateSuiteCode(1)).toBe("EC-00001");
});
it("genera EC-00345 para secuencia 345 (ejemplo de la doc)", () => {
expect(generateSuiteCode(345)).toBe("EC-00345");
});
it("genera EC-99999 para el máximo permitido", () => {
expect(generateSuiteCode(99999)).toBe("EC-99999");
});
it("rellena con ceros a la izquierda hasta 5 dígitos", () => {
expect(generateSuiteCode(7)).toBe("EC-00007");
expect(generateSuiteCode(42)).toBe("EC-00042");
expect(generateSuiteCode(1234)).toBe("EC-01234");
});
it("siempre empieza con EC-", () => {
for (let i = 1; i <= 10; i++) {
expect(generateSuiteCode(i).startsWith("EC-")).toBe(true);
}
});
it("lanza error si la secuencia es 0", () => {
expect(() => generateSuiteCode(0)).toThrow();
});
it("lanza error si la secuencia es negativa", () => {
expect(() => generateSuiteCode(-1)).toThrow();
});
it("lanza error si la secuencia supera 99999", () => {
expect(() => generateSuiteCode(100000)).toThrow();
});
});
describe("parseSuiteCode", () => {
it("extrae la secuencia de un código válido", () => {
expect(parseSuiteCode("EC-00345")).toBe(345);
expect(parseSuiteCode("EC-00001")).toBe(1);
expect(parseSuiteCode("EC-99999")).toBe(99999);
});
it("retorna null para formatos inválidos", () => {
expect(parseSuiteCode("EC-0034")).toBeNull(); // 4 dígitos
expect(parseSuiteCode("US-00345")).toBeNull(); // prefijo incorrecto
expect(parseSuiteCode("EC00345")).toBeNull(); // sin guión
expect(parseSuiteCode("")).toBeNull();
});
it("round-trip: generate → parse retorna la misma secuencia", () => {
const seq = 12345;
const code = generateSuiteCode(seq);
expect(parseSuiteCode(code)).toBe(seq);
});
});
describe("buildSuiteAddress", () => {
it("construye la dirección completa con la suite (doc §02)", () => {
const addr = buildSuiteAddress("EC-00345");
expect(addr).toBe(
"150 N Day St, Suite EC-00345, City of Orange, NJ 07050, EE.UU.",
);
});
it("contiene la dirección exacta de la bodega NJ documentada", () => {
const addr = buildSuiteAddress("EC-00001");
expect(addr).toContain("150 N Day St");
expect(addr).toContain("City of Orange");
expect(addr).toContain("NJ 07050");
});
});
});
@@ -0,0 +1,29 @@
/**
* Genera el código de Suite (casillero) asignado al cliente al registrarse.
* Formato: EC-XXXXX (número secuencial 5 dígitos) (doc §02 / §09)
* Ejemplo: EC-00345
*/
export function generateSuiteCode(sequence: number): string {
if (sequence < 1 || sequence > 99999) {
throw new Error(`Secuencia fuera de rango: ${sequence}. Debe estar entre 1 y 99999.`);
}
return `EC-${String(sequence).padStart(5, "0")}`;
}
/**
* Extrae el número de secuencia de un código de suite.
* Retorna null si el formato no es válido.
*/
export function parseSuiteCode(code: string): number | null {
const match = /^EC-(\d{5})$/.exec(code);
if (!match) return null;
return parseInt(match[1], 10);
}
/**
* Construye la dirección completa de la Suite para mostrar al cliente.
* (doc §02 y §09)
*/
export function buildSuiteAddress(suiteCode: string): string {
return `150 N Day St, Suite ${suiteCode}, City of Orange, NJ 07050, EE.UU.`;
}
@@ -0,0 +1,74 @@
import {
generateTrackingId,
isValidTrackingId,
} from "./tracking-id.util";
/**
* Tests del Tracking ID.
* Formato documentado en §08: EC-YYYYMMDD-XXXXXX
*/
describe("TrackingId Utils (doc §08)", () => {
describe("generateTrackingId", () => {
it("genera un ID con el formato EC-YYYYMMDD-XXXXXX", () => {
const id = generateTrackingId();
expect(id).toMatch(/^EC-\d{8}-\d{6}$/);
});
it("usa la fecha proporcionada correctamente", () => {
const date = new Date("2026-05-06T00:00:00.000Z");
const id = generateTrackingId(date);
// El año/mes/día depende de la zona horaria local del proceso
// Solo validamos el patrón
expect(id).toMatch(/^EC-\d{8}-\d{6}$/);
});
it("incluye el año 2026 si se pasa esa fecha", () => {
const date = new Date(2026, 4, 6); // May 6, 2026 — local
const id = generateTrackingId(date);
expect(id.startsWith("EC-2026")).toBe(true);
});
it("genera IDs distintos en llamadas consecutivas (probabilístico)", () => {
const ids = new Set(Array.from({ length: 20 }, () => generateTrackingId()));
// Con 1 millón de combinaciones, 20 llamadas deben ser todas distintas
expect(ids.size).toBe(20);
});
it("el prefijo es siempre EC-", () => {
for (let i = 0; i < 10; i++) {
expect(generateTrackingId().startsWith("EC-")).toBe(true);
}
});
it("el sufijo siempre tiene exactamente 6 dígitos", () => {
for (let i = 0; i < 10; i++) {
const parts = generateTrackingId().split("-");
expect(parts[2]).toHaveLength(6);
expect(/^\d{6}$/.test(parts[2])).toBe(true);
}
});
});
describe("isValidTrackingId", () => {
it("retorna true para IDs válidos", () => {
expect(isValidTrackingId("EC-20260506-082341")).toBe(true);
expect(isValidTrackingId("EC-20261231-000001")).toBe(true);
expect(isValidTrackingId("EC-20260101-999999")).toBe(true);
});
it("retorna false para IDs inválidos", () => {
expect(isValidTrackingId("EC-2026050-082341")).toBe(false); // fecha corta
expect(isValidTrackingId("EC-20260506-08234")).toBe(false); // sufijo corto
expect(isValidTrackingId("US-20260506-082341")).toBe(false); // prefijo incorrecto
expect(isValidTrackingId("EC-20260506082341")).toBe(false); // sin separador
expect(isValidTrackingId("EC-20260506-08234A")).toBe(false); // letras en sufijo
expect(isValidTrackingId("")).toBe(false);
});
it("los IDs generados siempre pasan la validación", () => {
for (let i = 0; i < 50; i++) {
expect(isValidTrackingId(generateTrackingId())).toBe(true);
}
});
});
});
@@ -0,0 +1,20 @@
/**
* Genera el Tracking ID único de un paquete.
* Formato documentado: EC-YYYYMMDD-XXXXXX (doc §08)
* Ejemplo: EC-20260506-082341
*/
export function generateTrackingId(date?: Date): string {
const d = date ?? new Date();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const random = String(Math.floor(Math.random() * 1_000_000)).padStart(6, "0");
return `EC-${year}${month}${day}-${random}`;
}
/**
* Valida que un Tracking ID tenga el formato correcto.
*/
export function isValidTrackingId(id: string): boolean {
return /^EC-\d{8}-\d{6}$/.test(id);
}
@@ -0,0 +1,58 @@
import { Test, TestingModule } from "@nestjs/testing";
import { HealthController } from "./health.controller";
import { PrismaService } from "../prisma/prisma.service";
describe("HealthController", () => {
let controller: HealthController;
let prismaService: { client: { $queryRaw: jest.Mock } };
beforeEach(async () => {
prismaService = {
client: {
$queryRaw: jest.fn().mockResolvedValue([{ "?column?": 1 }]),
},
};
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
providers: [
{
provide: PrismaService,
useValue: prismaService,
},
],
}).compile();
controller = module.get<HealthController>(HealthController);
});
it("debe estar definido", () => {
expect(controller).toBeDefined();
});
it("retorna status 'ok' cuando la DB responde", async () => {
const result = await controller.health();
expect(result.status).toBe("ok");
expect(result.checks.database).toBe("ok");
});
it("retorna status 'degraded' cuando la DB falla", async () => {
prismaService.client.$queryRaw.mockRejectedValueOnce(new Error("DB down"));
const result = await controller.health();
expect(result.status).toBe("degraded");
expect(result.checks.database).toBe("error");
});
it("el response incluye service, version y timestamp", async () => {
const result = await controller.health();
expect(result.service).toBe("moraworld-api");
expect(result.version).toBe("0.1.0");
expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it("timestamp es una fecha ISO válida", async () => {
const result = await controller.health();
const date = new Date(result.timestamp);
expect(date.getTime()).not.toBeNaN();
});
});
@@ -0,0 +1,110 @@
import { Test, TestingModule } from "@nestjs/testing";
import { TrackingController } from "./tracking.controller";
import { PrismaService } from "../prisma/prisma.service";
import { NotFoundException } from "@nestjs/common";
describe("TrackingController (doc §07 — búsqueda pública sin login)", () => {
let controller: TrackingController;
let prismaService: { client: { package: { findUnique: jest.Mock } } };
const mockPackage = {
trackingId: "EC-20260506-082341",
status: "REGISTRADO",
description: "Tenis Nike Air Max",
store: "Amazon",
vendorTracking: "1Z999AA10123456784",
senaeCategory: "REGIMEN_4X4",
createdAt: new Date("2026-05-06T10:00:00.000Z"),
updatedAt: new Date("2026-05-06T10:00:00.000Z"),
statusHistory: [
{
status: "REGISTRADO",
note: "Paquete registrado por el cliente",
createdAt: new Date("2026-05-06T10:00:00.000Z"),
},
],
};
beforeEach(async () => {
prismaService = {
client: {
package: {
findUnique: jest.fn().mockResolvedValue(mockPackage),
},
},
};
const module: TestingModule = await Test.createTestingModule({
controllers: [TrackingController],
providers: [
{
provide: PrismaService,
useValue: prismaService,
},
],
}).compile();
controller = module.get<TrackingController>(TrackingController);
});
it("debe estar definido", () => {
expect(controller).toBeDefined();
});
it("retorna el paquete cuando el Tracking ID es válido y existe", async () => {
const result = await controller.findByTrackingId("EC-20260506-082341");
expect(result.trackingId).toBe("EC-20260506-082341");
expect(result.status).toBe("REGISTRADO");
expect(result.description).toBe("Tenis Nike Air Max");
expect(result.timeline).toHaveLength(1);
});
it("incluye el timeline (historial de estados)", async () => {
const result = await controller.findByTrackingId("EC-20260506-082341");
expect(Array.isArray(result.timeline)).toBe(true);
expect(result.timeline[0].status).toBe("REGISTRADO");
});
it("lanza NotFoundException si el Tracking ID no existe en DB", async () => {
prismaService.client.package.findUnique.mockResolvedValueOnce(null);
await expect(
controller.findByTrackingId("EC-20260506-999999"),
).rejects.toThrow(NotFoundException);
});
it("lanza NotFoundException para formato de ID inválido (evita consultas innecesarias)", async () => {
await expect(
controller.findByTrackingId("INVALIDO"),
).rejects.toThrow(NotFoundException);
// No debe llegar a consultar la DB
expect(prismaService.client.package.findUnique).not.toHaveBeenCalled();
});
it("lanza NotFoundException para ID con prefijo incorrecto", async () => {
await expect(
controller.findByTrackingId("US-20260506-082341"),
).rejects.toThrow(NotFoundException);
});
it("lanza NotFoundException para ID con fecha malformada", async () => {
await expect(
controller.findByTrackingId("EC-2026050-082341"),
).rejects.toThrow(NotFoundException);
});
it("retorna null para senaeCategory si no está definida", async () => {
prismaService.client.package.findUnique.mockResolvedValueOnce({
...mockPackage,
senaeCategory: null,
});
const result = await controller.findByTrackingId("EC-20260506-082341");
expect(result.senaeCategory).toBeNull();
});
it("no expone datos sensibles (sin userId ni tenantId)", async () => {
const result = await controller.findByTrackingId("EC-20260506-082341") as Record<string, unknown>;
expect(result["userId"]).toBeUndefined();
expect(result["tenantId"]).toBeUndefined();
expect(result["declaredValue"]).toBeUndefined();
});
});
@@ -0,0 +1,60 @@
import { Controller, Get, Param, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { isValidTrackingId } from "../common/utils/tracking-id.util";
@Controller("tracking")
export class TrackingController {
constructor(private readonly prisma: PrismaService) {}
/**
* GET /api/tracking/:id
* Búsqueda pública por Tracking ID — sin necesidad de login (doc §07 Módulo de Tracking).
* Retorna el estado y el historial del paquete.
*/
@Get(":id")
async findByTrackingId(@Param("id") id: string) {
if (!isValidTrackingId(id)) {
throw new NotFoundException(
`Formato de Tracking ID inválido. Esperado: EC-YYYYMMDD-XXXXXX. Recibido: ${id}`,
);
}
const pkg = await this.prisma.client.package.findUnique({
where: { trackingId: id },
select: {
trackingId: true,
status: true,
description: true,
store: true,
vendorTracking: true,
senaeCategory: true,
createdAt: true,
updatedAt: true,
statusHistory: {
orderBy: { createdAt: "asc" },
select: {
status: true,
note: true,
createdAt: true,
},
},
},
});
if (!pkg) {
throw new NotFoundException(`No se encontró un paquete con el Tracking ID: ${id}`);
}
return {
trackingId: pkg.trackingId,
status: pkg.status,
description: pkg.description,
store: pkg.store ?? null,
vendorTracking: pkg.vendorTracking ?? null,
senaeCategory: pkg.senaeCategory ?? null,
timeline: pkg.statusHistory,
lastUpdate: pkg.updatedAt,
registered: pkg.createdAt,
};
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { TrackingController } from "./tracking.controller";
@Module({
controllers: [TrackingController],
})
export class TrackingModule {}
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Calculadora de Envíos SENAE — Moraworld Imports",
description: "Calcula el costo total de tu envío desde EE.UU. a Ecuador incluyendo FODINFA, IVA, arancel y flete. Sin registro.",
};
export default function CalculadoraPage() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
return (
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
<div style={{ maxWidth: 700, margin: "0 auto" }}>
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>
Calculadora SENAE
</h1>
<p style={{ color: "#6B7280" }}>
Sin necesidad de registro. Conoce el costo total antes de comprar.
</p>
</div>
{/* Nota: el formulario interactivo se implementa en Fase 1 con client component */}
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB", marginBottom: 24,
}}>
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
Ingresa los datos de tu paquete
</h2>
<p style={{ color: "#6B7280", fontSize: ".88rem", marginBottom: 20 }}>
El formulario interactivo se habilitará en la Fase 1. Por ahora puedes usar el
endpoint de la API directamente:
</p>
<div style={{
background: "#0D1117", borderRadius: 10, padding: "16px 20px",
fontFamily: "monospace", fontSize: ".8rem", color: "#A5B4FC",
lineHeight: 2,
}}>
<div style={{ color: "rgba(255,255,255,.3)", marginBottom: 8 }}>{`// GET /api/calculator`}</div>
<div>
<span style={{ color: "#6EE7B7" }}>GET</span>{" "}
{apiUrl}/api/calculator?<span style={{ color: "#FCD34D" }}>value</span>=150&
<span style={{ color: "#FCD34D" }}>weight</span>=2&
<span style={{ color: "#FCD34D" }}>category</span>=REGIMEN_4X4
</div>
<div style={{ marginTop: 12, color: "rgba(255,255,255,.3)" }}>
{`// Parámetros: value, weight, category, length, width, height, shipments`}
</div>
</div>
</div>
{/* Categorías SENAE informativas */}
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB",
}}>
<h2 style={{ fontWeight: 700, marginBottom: 20, fontSize: "1rem" }}>
Categorías SENAE aplicables
</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
{[
{ name: "Régimen 4×4", rate: "0%", color: "#10B981", bg: "#D1FAE5", desc: "Hasta $400 · 4 kg · 4 envíos/año" },
{ name: "Categoría B", rate: "10%", color: "#1D4ED8", bg: "#DBEAFE", desc: "Bienes generales" },
{ name: "Categoría C", rate: "20%", color: "#FF6B00", bg: "#FFEDD5", desc: "Textiles, calzado, hogar" },
{ name: "Categoría D", rate: "015%", color: "#8B5CF6", bg: "#EDE9FE", desc: "Electrónicos · puede requerir INEN" },
].map((c) => (
<div key={c.name} style={{
background: c.bg, borderRadius: 10, padding: 16, textAlign: "center",
}}>
<div style={{ fontWeight: 800, marginBottom: 4 }}>{c.name}</div>
<div style={{ fontSize: "1.8rem", fontWeight: 900, color: c.color, lineHeight: 1, marginBottom: 6 }}>{c.rate}</div>
<div style={{ fontSize: ".75rem", color: "#4B5563" }}>{c.desc}</div>
</div>
))}
</div>
<div style={{ marginTop: 20, padding: 16, background: "#F3F4F6", borderRadius: 8 }}>
<p style={{ fontSize: ".82rem", color: "#6B7280", lineHeight: 1.6 }}>
<strong>Fórmula:</strong> Flete (Peso × $3.50/lb) + Seguro (2%) + FODINFA (0.5%) + Arancel (% categoría) + IVA (15%)
</p>
</div>
</div>
</div>
</main>
);
}
+440 -105
View File
@@ -1,115 +1,450 @@
async function getApiHealth() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
try {
const res = await fetch(`${apiUrl}/api/health`, {
next: { revalidate: 10 },
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
import type { Metadata } from "next";
import Link from "next/link";
export default async function HomePage() {
const health = await getApiHealth();
export const metadata: Metadata = {
title: "Moraworld Imports — Tu casillero en New Jersey para Ecuador",
description:
"Compra en Amazon, eBay, Walmart y más. Recibe en tu Suite en New Jersey y enviamos a Ecuador con gestión aduanera SENAE incluida.",
keywords: ["casillero New Jersey", "importaciones Ecuador", "SENAE", "Amazon Ecuador", "envíos EE.UU. Ecuador"],
};
// ─── Datos estáticos de la landing (sin DB, SSG) ──────────────
const TRACKING_STATES = [
{ num: "01", name: "REGISTRADO", color: "#6B7280", desc: "El cliente registró la compra" },
{ num: "02", name: "EN_TRANSITO_BODEGA", color: "#F59E0B", desc: "El vendedor despachó hacia bodega NJ" },
{ num: "03", name: "RECIBIDO_BODEGA", color: "#3B82F6", desc: "Bodega NJ confirmó recepción" },
{ num: "04", name: "EN_VERIFICACION", color: "#8B5CF6", desc: "Pesando, midiendo y fotografiando" },
{ num: "05", name: "VERIFICADO", color: "#10B981", desc: "Peso confirmado. Cobro aplicado." },
{ num: "06", name: "DECLARACION_ADUANERA", color: "#0057FF", desc: "DSI aprobada por la SENAE" },
{ num: "07", name: "EN_TRANSITO_ECUADOR", color: "#F97316", desc: "En vuelo hacia Ecuador" },
{ num: "08", name: "EN_ADUANA_ECUADOR", color: "#EF4444", desc: "Inspección en aduana Ecuador" },
{ num: "09", name: "LISTO_ENTREGA", color: "#84CC16", desc: "Listo para entrega al cliente" },
{ num: "10", name: "ENTREGADO", color: "#10B981", desc: "Paquete entregado en Ecuador ✓" },
{ num: "11", name: "INCIDENCIA", color: "#EF4444", desc: "Problema reportado ⚠" },
];
const SENAE_CATEGORIES = [
{ name: "Régimen 4×4", rate: "0%", color: "#10B981", bg: "#D1FAE5", desc: "Hasta 4 envíos/año · máx. $400 · máx. 4 kg · Sin arancel" },
{ name: "Categoría B", rate: "10%", color: "#1D4ED8", bg: "#DBEAFE", desc: "Bienes de consumo general" },
{ name: "Categoría C", rate: "20%", color: "#FF6B00", bg: "#FFEDD5", desc: "Textiles, calzado, hogar" },
{ name: "Categoría D", rate: "015%", color: "#8B5CF6", bg: "#EDE9FE", desc: "Electrónicos · puede requerir INEN" },
];
const MODULES = [
{ icon: "🌐", title: "Landing Pública", desc: "Calculadora SENAE, tracking sin login, tarifas y FAQ", tag: "Sin registro" },
{ icon: "👤", title: "Portal Cliente", desc: "Suite personalizada, pre-alertas, mis paquetes, historial", tag: "B2C · Ecuador" },
{ icon: "🏭", title: "Portal Bodega NJ", desc: "Recepción, peso, fotos, despacho desde 150 N Day St", tag: "Operador · NJ" },
{ icon: "⚙️", title: "Portal Admin", desc: "Dashboard, usuarios, tarifas, reportes, auditoría", tag: "Admin / Super Admin" },
{ icon: "🛃", title: "Módulo SENAE", desc: "DSI automática, cálculo FODINFA+IVA+Arancel, WebService", tag: "Agente Aduanero" },
{ icon: "📍", title: "Tracking", desc: "ID único EC-YYYYMMDD-XXXXXX · 11 estados · búsqueda pública", tag: "Todos los roles" },
{ icon: "📄", title: "Pre-alerta", desc: "Aviso previo con factura antes de llegada a bodega", tag: "Cliente" },
{ icon: "🚢", title: "Carga Pesada B2B", desc: "Pallets, contenedores, cotización especial, INEN", tag: "Importadores" },
];
const ROLES = [
{ icon: "👑", name: "Super Admin", desc: "Acceso total. Gestiona tenants y config del sistema.", badge: "Acceso total", bg: "#EEF3FF" },
{ icon: "🏢", name: "Admin Empresa", desc: "Administra su tenant: usuarios, tarifas, reportes.", badge: "Tenant propio", bg: "#EDE9FE" },
{ icon: "📦", name: "Operador Bodega", desc: "Personal en NJ: verifica, pesa, fotografía y despacha.", badge: "Bodega NJ", bg: "#FEF3C7" },
{ icon: "🛃", name: "Agente Aduanero", desc: "Revisa y aprueba declaraciones ante la SENAE.", badge: "SENAE", bg: "#D1FAE5" },
{ icon: "🧑‍💻", name: "Cliente Final", desc: "Registra compras, pre-alertas y hace seguimiento.", badge: "B2C · Ecuador", bg: "#FFF3E8" },
{ icon: "🎧", name: "Soporte", desc: "Solo lectura: puede ver info de clientes y paquetes.", badge: "Solo lectura", bg: "#F3F4F6" },
];
// ─── Componentes ──────────────────────────────────────────────
function Navbar() {
return (
<main>
<header
style={{
borderBottom: "1px solid var(--gray-200)",
padding: "16px 24px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontWeight: 800, fontSize: "1.2rem", color: "var(--primary)" }}>
Moraworld<span style={{ color: "var(--accent)" }}>.</span>Imports
</span>
<span
style={{
fontSize: ".75rem",
fontWeight: 600,
padding: "4px 10px",
borderRadius: 999,
background: health?.status === "ok" ? "#D1FAE5" : "#FEF3C7",
color: health?.status === "ok" ? "#065F46" : "#92400E",
}}
>
API: {health?.status === "ok" ? "conectada" : "local / pendiente"}
</span>
</header>
<nav style={{
position: "sticky", top: 0, zIndex: 50,
background: "rgba(255,255,255,0.95)",
backdropFilter: "blur(12px)",
borderBottom: "1px solid #E5E7EB",
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "0 40px", height: 60,
}}>
<span style={{ fontWeight: 800, fontSize: "1.15rem", color: "#111827" }}>
Moraworld<span style={{ color: "#FF6B00" }}>.</span>Imports
</span>
<div style={{ display: "flex", gap: 24, alignItems: "center", fontSize: ".9rem" }}>
<Link href="/calculadora" style={{ color: "#4B5563" }}>Calculadora</Link>
<Link href="/tracking" style={{ color: "#4B5563" }}>Tracking</Link>
<Link href="/carga-pesada" style={{ color: "#4B5563" }}>Carga Pesada</Link>
<Link href="/portal" style={{
background: "#0057FF", color: "#fff",
padding: "8px 20px", borderRadius: 8, fontWeight: 600,
}}>Mi Portal</Link>
</div>
</nav>
);
}
<section
style={{
background: "linear-gradient(135deg, #0D1117 0%, #0D2150 100%)",
padding: "80px 24px",
color: "#fff",
}}
>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<p
style={{
function Hero() {
return (
<section style={{
background: "linear-gradient(135deg, #0D1117 0%, #0D2150 100%)",
padding: "100px 40px", color: "#fff",
}}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{
display: "inline-block",
background: "rgba(0,87,255,.15)", border: "1px solid rgba(0,87,255,.3)",
color: "#7AADFF", fontSize: ".8rem", fontWeight: 600,
padding: "6px 14px", borderRadius: 999, marginBottom: 24,
}}>
ISO/IEC 27001 · SENAE Autorizado · Cuenca, Ecuador
</div>
<h1 style={{ fontSize: "clamp(2rem,5vw,3.2rem)", fontWeight: 800, lineHeight: 1.15, marginBottom: 20 }}>
Tu casillero en <span style={{ color: "#FF6B00" }}>New Jersey</span>
<br />para recibir en Ecuador
</h1>
<p style={{ color: "#9CA3AF", maxWidth: 560, fontSize: "1.05rem", marginBottom: 16 }}>
Compra en Amazon, eBay, Walmart y más. Te asignamos una Suite personal en nuestra
bodega en NJ. Gestionamos el envío y la declaración aduanera SENAE por ti.
</p>
<div style={{
background: "rgba(0,87,255,.1)", border: "1px solid rgba(0,87,255,.25)",
borderRadius: 10, padding: "14px 18px", marginBottom: 32,
fontFamily: "monospace", fontSize: ".9rem", color: "#A5B4FC", maxWidth: 520,
}}>
📍 <strong style={{ color: "#FF6B00" }}>Tu Suite:</strong>{" "}
150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050, EE.UU.
</div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<Link href="/registro" style={{
background: "#0057FF", color: "#fff",
padding: "14px 28px", borderRadius: 8, fontWeight: 700, fontSize: ".95rem",
}}>
Crear mi Suite gratis
</Link>
<Link href="/calculadora" style={{
border: "2px solid rgba(255,255,255,.2)", color: "#9CA3AF",
padding: "12px 26px", borderRadius: 8, fontSize: ".9rem",
}}>
Calcular mi envío
</Link>
</div>
<div style={{ display: "flex", gap: 32, marginTop: 48, flexWrap: "wrap" }}>
{[
["🏢", "Moraworld Imports S.A.S.", "Ecuador · RUC activo"],
["🏭", "Mora Global Import LLC", "New Jersey, EE.UU."],
["🛃", "Autorización SENAE", "Declaración automática"],
].map(([icon, title, sub]) => (
<div key={title} style={{ display: "flex", gap: 10, alignItems: "flex-start" }}>
<span style={{ fontSize: "1.5rem" }}>{icon}</span>
<div>
<div style={{ fontWeight: 600, fontSize: ".88rem", color: "#fff" }}>{title}</div>
<div style={{ fontSize: ".78rem", color: "#6B7280" }}>{sub}</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
function ServicesSection() {
return (
<section style={{ padding: "80px 40px", background: "#F3F4F6" }}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{ textAlign: "center", marginBottom: 48 }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>Dos servicios, un solo sistema</h2>
<p style={{ color: "#6B7280", fontSize: ".95rem" }}>Casillero personal para compras online y carga pesada para importadores B2B.</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24 }}>
<div style={{ background: "#fff", borderRadius: 16, padding: 32, border: "2px solid #BFDBFE" }}>
<div style={{ fontSize: "2.5rem", marginBottom: 12 }}>📦</div>
<h3 style={{ fontWeight: 800, fontSize: "1.2rem", marginBottom: 8 }}>Casillero Personal</h3>
<p style={{ color: "#6B7280", marginBottom: 16 }}>
Para compras individuales en Amazon, eBay, Walmart y más. Suite personalizada, tracking en tiempo real
y declaración SENAE automática bajo el régimen 4×4.
</p>
<ul style={{ color: "#374151", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 6, fontSize: ".88rem" }}>
{["Suite virtual asignada al registrarse", "Pre-alerta con carga de factura", "Calculadora de costos SENAE", "11 estados de tracking en tiempo real", "Notificaciones WhatsApp · SMS · Email"].map(i => (
<li key={i}>{i}</li>
))}
</ul>
<Link href="/casillero" style={{
display: "inline-block", marginTop: 20, background: "#0057FF", color: "#fff",
padding: "10px 20px", borderRadius: 8, fontWeight: 600, fontSize: ".88rem",
}}>
Conocer más
</Link>
</div>
<div style={{ background: "#fff", borderRadius: 16, padding: 32, border: "2px solid #FED7AA" }}>
<div style={{ fontSize: "2.5rem", marginBottom: 12 }}>🚢</div>
<h3 style={{ fontWeight: 800, fontSize: "1.2rem", marginBottom: 8 }}>Carga Pesada / Pallets B2B</h3>
<p style={{ color: "#6B7280", marginBottom: 16 }}>
Para importadores mayoristas: pallets, contenedores, volumen alto. Cotización personalizada,
certificación INEN y declaración formal DAI ante la SENAE.
</p>
<ul style={{ color: "#374151", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 6, fontSize: ".88rem" }}>
{["Cotización especial por tipo de mercancía", "Verificación de requisitos INEN", "Declaración formal DAI", "Asesoría en partidas arancelarias", "Expediente B2B con ID de seguimiento"].map(i => (
<li key={i}>{i}</li>
))}
</ul>
<Link href="/carga-pesada/cotizacion" style={{
display: "inline-block", marginTop: 20, background: "#FF6B00", color: "#fff",
padding: "10px 20px", borderRadius: 8, fontWeight: 600, fontSize: ".88rem",
}}>
Solicitar cotización
</Link>
</div>
</div>
</div>
</section>
);
}
function ModulesSection() {
return (
<section style={{ padding: "80px 40px", background: "#fff" }}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{ textAlign: "center", marginBottom: 48 }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>8 módulos integrados</h2>
<p style={{ color: "#6B7280" }}>Toda la operación logística cubierta en una sola plataforma.</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16 }}>
{MODULES.map((m) => (
<div key={m.title} style={{
background: "#F9FAFB", borderRadius: 12, padding: 20,
border: "1px solid #E5E7EB", transition: "box-shadow .2s",
}}>
<div style={{ fontSize: "1.8rem", marginBottom: 10 }}>{m.icon}</div>
<div style={{ fontWeight: 700, fontSize: ".9rem", marginBottom: 6 }}>{m.title}</div>
<p style={{ color: "#6B7280", fontSize: ".8rem", marginBottom: 10, lineHeight: 1.5 }}>{m.desc}</p>
<span style={{
background: "#EEF3FF", color: "#0057FF",
fontSize: ".68rem", fontWeight: 700, padding: "2px 8px",
borderRadius: 999,
}}>{m.tag}</span>
</div>
))}
</div>
</div>
</section>
);
}
function TrackingSection() {
return (
<section style={{ padding: "80px 40px", background: "#F3F4F6" }}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 48, alignItems: "start" }}>
<div>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 12 }}>
Tracking en tiempo real
</h2>
<p style={{ color: "#6B7280", marginBottom: 16 }}>
Cada paquete recibe un ID único con 11 estados que cubren todo el ciclo de vida.
Búsqueda pública sin necesidad de crear cuenta.
</p>
<div style={{
background: "#0D1117", borderRadius: 10, padding: "14px 18px",
fontFamily: "monospace", fontSize: ".88rem", color: "#A5B4FC",
marginBottom: 20, border: "1px solid rgba(0,87,255,.3)",
}}>
Formato: <strong style={{ color: "#FF6B00" }}>EC-YYYYMMDD-XXXXXX</strong><br />
Ejemplo: <strong>EC-20260506-082341</strong>
</div>
<Link href="/tracking" style={{
background: "#0057FF", color: "#fff",
padding: "12px 24px", borderRadius: 8, fontWeight: 600, fontSize: ".9rem",
display: "inline-block",
background: "rgba(0,87,255,.15)",
border: "1px solid rgba(0,87,255,.3)",
color: "#7AADFF",
fontSize: ".8rem",
fontWeight: 600,
padding: "6px 14px",
borderRadius: 999,
marginBottom: 24,
}}
>
Fase 0 Fundación del software
</p>
<h1 style={{ fontSize: "clamp(2rem, 5vw, 3rem)", fontWeight: 800, lineHeight: 1.15, marginBottom: 16 }}>
Tu casillero en <em style={{ color: "var(--accent)", fontStyle: "normal" }}>New Jersey</em>
<br />
para recibir en Ecuador
</h1>
<p style={{ color: "#9CA3AF", maxWidth: 520, marginBottom: 32 }}>
Monorepo activo: Next.js + NestJS + PostgreSQL. Desarrollo local con Docker; despliegue en Coolify.
</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<a
href="/status"
style={{
background: "var(--primary)",
color: "#fff",
padding: "14px 28px",
borderRadius: 8,
fontWeight: 600,
}}
>
Ver estado del sistema
</a>
<span
style={{
border: "2px solid rgba(255,255,255,.2)",
color: "#9CA3AF",
padding: "12px 26px",
borderRadius: 8,
fontSize: ".9rem",
}}
>
Prototipos HTML en la raíz del repo
</span>
}}>
Buscar mi paquete
</Link>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{TRACKING_STATES.map((s) => (
<div key={s.num} style={{
display: "flex", gap: 12, alignItems: "center",
background: "#fff", borderRadius: 8, padding: "8px 14px",
border: "1px solid #E5E7EB",
}}>
<div style={{
width: 10, height: 10, borderRadius: "50%",
background: s.color, flexShrink: 0,
}} />
<span style={{ fontFamily: "monospace", fontSize: ".68rem", color: "#9CA3AF", minWidth: 22 }}>{s.num}</span>
<span style={{ fontFamily: "monospace", fontSize: ".78rem", fontWeight: 700, flex: 1 }}>{s.name}</span>
<span style={{ fontSize: ".75rem", color: "#6B7280" }}>{s.desc}</span>
</div>
))}
</div>
</div>
</div>
</section>
);
}
function CalculatorPreviewSection() {
return (
<section style={{ padding: "80px 40px", background: "#fff" }}>
<div style={{ maxWidth: 1100, margin: "0 auto", textAlign: "center" }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>
Calculadora SENAE gratuita
</h2>
<p style={{ color: "#6B7280", marginBottom: 40 }}>
Sin registro. Conoce el costo total de tu envío antes de comprar.
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 16, marginBottom: 32 }}>
{SENAE_CATEGORIES.map((c) => (
<div key={c.name} style={{
borderRadius: 12, padding: 24, background: c.bg,
border: `1px solid ${c.color}30`, textAlign: "center",
}}>
<div style={{ fontWeight: 800, marginBottom: 4 }}>{c.name}</div>
<div style={{ fontSize: "2.2rem", fontWeight: 900, color: c.color, lineHeight: 1, marginBottom: 8 }}>{c.rate}</div>
<div style={{ fontSize: ".75rem", color: "#4B5563" }}>{c.desc}</div>
</div>
))}
</div>
<div style={{
background: "#0D1117", borderRadius: 12, padding: "20px 24px",
fontFamily: "monospace", fontSize: ".82rem", color: "#A5B4FC",
textAlign: "left", maxWidth: 600, margin: "0 auto 28px",
lineHeight: 2, border: "1px solid rgba(255,255,255,.08)",
}}>
<span style={{ color: "rgba(255,255,255,.25)" }}>// Fórmula (doc §15)</span><br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>Flete</span> = Peso_final × <span style={{ color: "#FCD34D" }}>$3.50</span>/lb<br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>Seguro</span> = Valor × <span style={{ color: "#FCD34D" }}>2%</span><br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>FODINFA</span> = Valor × <span style={{ color: "#FCD34D" }}>0.5%</span><br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>Arancel</span> = Valor × <span style={{ color: "#FCD34D" }}>% categoría</span><br />
<span style={{ color: "#6EE7B7", fontWeight: 700 }}>IVA</span> = (Valor + FODINFA + Arancel) × <span style={{ color: "#FCD34D" }}>15%</span><br />
<span style={{ color: "#F9A8D4", fontWeight: 700 }}>TOTAL</span> = Flete + Seguro + FODINFA + Arancel + IVA
</div>
<Link href="/calculadora" style={{
background: "#0057FF", color: "#fff",
padding: "14px 32px", borderRadius: 8, fontWeight: 700,
display: "inline-block", fontSize: ".95rem",
}}>
Calcular mi envío
</Link>
</div>
</section>
);
}
function RolesSection() {
return (
<section style={{ padding: "80px 40px", background: "#F3F4F6" }}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div style={{ textAlign: "center", marginBottom: 48 }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>6 roles, accesos diferenciados</h2>
<p style={{ color: "#6B7280" }}>Cada usuario tiene exactamente lo que necesita. Ni más, ni menos.</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 16 }}>
{ROLES.map((r) => (
<div key={r.name} style={{
background: r.bg, borderRadius: 12, padding: 20,
border: "1px solid #E5E7EB",
}}>
<div style={{ fontSize: "1.5rem", marginBottom: 10 }}>{r.icon}</div>
<div style={{ fontWeight: 700, marginBottom: 6 }}>{r.name}</div>
<p style={{ color: "#6B7280", fontSize: ".82rem", marginBottom: 12, lineHeight: 1.5 }}>{r.desc}</p>
<span style={{
background: "rgba(0,0,0,.06)", fontSize: ".7rem", fontWeight: 700,
padding: "2px 10px", borderRadius: 999,
}}>{r.badge}</span>
</div>
))}
</div>
</div>
</section>
);
}
function CompaniesSection() {
return (
<section style={{ padding: "80px 40px", background: "#fff" }}>
<div style={{ maxWidth: 1100, margin: "0 auto", textAlign: "center" }}>
<h2 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>Operación legal en dos países</h2>
<p style={{ color: "#6B7280", marginBottom: 40 }}>Empresas constituidas legalmente en Ecuador y EE.UU.</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24, maxWidth: 800, margin: "0 auto" }}>
{[
{
flag: "🇪🇨",
name: "Moraworld Imports S.A.S.",
detail: "Constituida en Ecuador. Representante legal en Cuenca. RUC activo, permisos aduaneros vigentes ante la SENAE.",
badge: "Ecuador · S.A.S. · SENAE · Cuenca",
},
{
flag: "🇺🇸",
name: "Mora Global Import LLC",
detail: "LLC registrada en New Jersey, EE.UU. Bodega operativa en 150 N Day St, City of Orange, NJ 07050.",
badge: "New Jersey · LLC · NJ 07050",
},
].map((c) => (
<div key={c.name} style={{
background: "#fff", borderRadius: 16, padding: 28,
border: "2px solid #E5E7EB", display: "flex", gap: 16, alignItems: "flex-start",
}}>
<span style={{ fontSize: "2.5rem" }}>{c.flag}</span>
<div style={{ textAlign: "left" }}>
<div style={{ fontWeight: 800, marginBottom: 6 }}>{c.name}</div>
<p style={{ color: "#6B7280", fontSize: ".82rem", lineHeight: 1.6, marginBottom: 10 }}>{c.detail}</p>
<span style={{
background: "#EEF3FF", color: "#0057FF",
fontSize: ".7rem", fontWeight: 700, padding: "2px 10px", borderRadius: 999,
}}>{c.badge}</span>
</div>
</div>
))}
</div>
</section>
</div>
</section>
);
}
function Footer() {
return (
<footer style={{
background: "#0D1117", padding: "48px 40px", color: "rgba(255,255,255,.5)",
textAlign: "center",
}}>
<div style={{ fontSize: "1.1rem", fontWeight: 800, color: "#fff", marginBottom: 8 }}>
Moraworld<span style={{ color: "#FF6B00" }}>.</span>Imports
</div>
<p style={{ fontSize: ".82rem", marginBottom: 20 }}>
Moraworld Imports S.A.S. · Mora Global Import LLC
</p>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "center", marginBottom: 24 }}>
{["ISO/IEC 27001", "SENAE Autorizado", "INEN", "LOPDP Ecuador", "Multi-Tenant"].map((b) => (
<span key={b} style={{
background: "rgba(255,255,255,.07)", color: "rgba(255,255,255,.5)",
fontSize: ".7rem", fontWeight: 700, padding: "4px 12px", borderRadius: 999,
}}>{b}</span>
))}
</div>
<div style={{ display: "flex", gap: 24, justifyContent: "center", flexWrap: "wrap", fontSize: ".82rem" }}>
<Link href="/como-funciona">Cómo funciona</Link>
<Link href="/calculadora">Calculadora</Link>
<Link href="/tracking">Tracking</Link>
<Link href="/tarifas">Tarifas</Link>
<Link href="/quienes-somos">Quiénes somos</Link>
<Link href="/casillero">Casillero</Link>
<Link href="/carga-pesada">Carga Pesada</Link>
</div>
</footer>
);
}
// ─── Página principal ─────────────────────────────────────────
<section style={{ padding: "48px 24px", maxWidth: 1100, margin: "0 auto" }}>
<h2 style={{ fontSize: "1.25rem", fontWeight: 700, marginBottom: 16 }}>Próximos pasos (Fase 1)</h2>
<ul style={{ color: "var(--gray-500)", paddingLeft: 20, display: "flex", flexDirection: "column", gap: 8 }}>
<li>Registro e inicio de sesión con JWT + MFA</li>
<li>Asignación automática de Suite (EC-XXXXX)</li>
<li>Pre-alertas y registro de paquetes</li>
<li>Migración del portal desde portal-cliente.html</li>
</ul>
</section>
</main>
export default function HomePage() {
return (
<>
<Navbar />
<Hero />
<ServicesSection />
<ModulesSection />
<TrackingSection />
<CalculatorPreviewSection />
<RolesSection />
<CompaniesSection />
<Footer />
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Rastrear Paquete — Moraworld Imports",
description: "Busca tu paquete con el Tracking ID (formato EC-YYYYMMDD-XXXXXX). Sin necesidad de iniciar sesión.",
};
export default function TrackingPage() {
return (
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
<div style={{ maxWidth: 600, margin: "0 auto" }}>
<div style={{ marginBottom: 32 }}>
<Link href="/" style={{ color: "#6B7280", fontSize: ".88rem" }}> Inicio</Link>
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginTop: 8, marginBottom: 8 }}>
Rastrear paquete
</h1>
<p style={{ color: "#6B7280" }}>
Ingresa tu Tracking ID para ver el estado de tu paquete. No necesitas iniciar sesión.
</p>
</div>
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB", marginBottom: 24,
}}>
{/* Formulario — cliente component en Fase 1 */}
<label style={{ display: "block", fontWeight: 600, marginBottom: 8, fontSize: ".9rem" }}>
Tracking ID
</label>
<div style={{
background: "#F3F4F6", borderRadius: 8, padding: "12px 16px",
fontFamily: "monospace", color: "#9CA3AF", fontSize: ".9rem",
border: "1px solid #E5E7EB", marginBottom: 8,
}}>
EC-20260506-082341
</div>
<p style={{ color: "#9CA3AF", fontSize: ".78rem", marginBottom: 20 }}>
Formato: <strong style={{ fontFamily: "monospace" }}>EC-YYYYMMDD-XXXXXX</strong>
</p>
<div style={{
background: "#EEF3FF", borderRadius: 8, padding: "12px 16px",
fontSize: ".84rem", color: "#1D4ED8",
}}>
El formulario de búsqueda interactiva se habilita en Fase 1.
Por ahora puedes usar la API: <code style={{ fontFamily: "monospace" }}>GET /api/tracking/:id</code>
</div>
</div>
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB",
}}>
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
11 estados del ciclo de vida
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{[
{ num: "01", name: "REGISTRADO", color: "#6B7280", desc: "Cliente registró la compra" },
{ num: "02", name: "EN_TRANSITO_BODEGA", color: "#F59E0B", desc: "Despachado hacia NJ" },
{ num: "03", name: "RECIBIDO_BODEGA", color: "#3B82F6", desc: "Bodega NJ confirmó recepción" },
{ num: "04", name: "EN_VERIFICACION", color: "#8B5CF6", desc: "Pesando y fotografiando" },
{ num: "05", name: "VERIFICADO", color: "#10B981", desc: "Peso confirmado" },
{ num: "06", name: "DECLARACION_ADUANERA", color: "#0057FF", desc: "DSI aprobada por SENAE" },
{ num: "07", name: "EN_TRANSITO_ECUADOR", color: "#F97316", desc: "En vuelo a Ecuador" },
{ num: "08", name: "EN_ADUANA_ECUADOR", color: "#EF4444", desc: "Inspección en aduana" },
{ num: "09", name: "LISTO_ENTREGA", color: "#84CC16", desc: "Listo para entrega" },
{ num: "10", name: "ENTREGADO", color: "#10B981", desc: "Entregado al cliente ✓" },
{ num: "11", name: "INCIDENCIA", color: "#EF4444", desc: "Problema reportado ⚠" },
].map((s) => (
<div key={s.num} style={{
display: "flex", gap: 10, alignItems: "center",
padding: "8px 12px", background: "#F9FAFB",
borderRadius: 6, border: "1px solid #E5E7EB",
}}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: s.color, flexShrink: 0 }} />
<span style={{ fontFamily: "monospace", fontSize: ".65rem", color: "#9CA3AF", minWidth: 18 }}>{s.num}</span>
<span style={{ fontFamily: "monospace", fontSize: ".75rem", fontWeight: 700, flex: 1 }}>{s.name}</span>
<span style={{ fontSize: ".72rem", color: "#6B7280" }}>{s.desc}</span>
</div>
))}
</div>
</div>
</div>
</main>
);
}