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

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

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

Roles sincronizados con schema: SUPER_ADMIN, ADMIN_EMPRESA,
OPERADOR_BODEGA, AGENTE_ADUANERO, CLIENTE, SOPORTE
This commit is contained in:
Lizandro Guarnizo
2026-06-01 09:42:38 -05:00
parent 93db76897d
commit 4872053fd1
57 changed files with 4330 additions and 192 deletions
+21 -2
View File
@@ -18,7 +18,16 @@
"@nestjs/common": "^11.1.0",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.0",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.0",
"@nestjs/throttler": "^6.5.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"otplib": "^13.4.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
@@ -26,21 +35,31 @@
"@nestjs/cli": "^11.0.7",
"@nestjs/schematics": "^11.0.5",
"@nestjs/testing": "^11.1.0",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.21",
"@types/passport-jwt": "^4.0.1",
"jest": "^29.7.0",
"ts-jest": "^29.3.4",
"typescript": "^5.8.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s", "!**/*.module.ts", "!**/main.ts"],
"collectCoverageFrom": [
"**/*.(t|j)s",
"!**/*.module.ts",
"!**/main.ts"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"coverageThreshold": {
+10
View File
@@ -4,6 +4,11 @@ import { HealthModule } from "./health/health.module";
import { PrismaModule } from "./prisma/prisma.module";
import { CalculatorModule } from "./calculator/calculator.module";
import { TrackingModule } from "./tracking/tracking.module";
import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module";
import { PackagesModule } from "./packages/packages.module";
import { PreAlertsModule } from "./pre-alerts/pre-alerts.module";
import { B2BModule } from "./b2b/b2b.module";
@Module({
imports: [
@@ -15,6 +20,11 @@ import { TrackingModule } from "./tracking/tracking.module";
HealthModule,
CalculatorModule,
TrackingModule,
AuthModule,
UsersModule,
PackagesModule,
PreAlertsModule,
B2BModule,
],
})
export class AppModule {}
+61
View File
@@ -0,0 +1,61 @@
import {
Controller, Post, Get, Body, Req, UseGuards, HttpCode, HttpStatus,
} from "@nestjs/common";
import { AuthService } from "./auth.service";
import { RegisterDto, LoginDto, RefreshDto, SetupMfaDto } from "./dto/auth.dto";
import { JwtAuthGuard } from "./guards/auth.guard";
import { CurrentUser } from "./decorators/current-user.decorator";
@Controller("auth")
export class AuthController {
constructor(private auth: AuthService) {}
/** POST /api/auth/register — Registro público (doc §09 paso 1) */
@Post("register")
register(@Body() dto: RegisterDto) {
return this.auth.register(dto);
}
/** POST /api/auth/login — Login con JWT + MFA opcional */
@Post("login")
@HttpCode(HttpStatus.OK)
login(@Body() dto: LoginDto, @Req() req: any) {
return this.auth.login(dto, req.ip);
}
/** POST /api/auth/refresh — Rotar refresh token */
@Post("refresh")
@HttpCode(HttpStatus.OK)
refresh(@Body() dto: RefreshDto) {
return this.auth.refresh(dto.refreshToken);
}
/** POST /api/auth/logout */
@Post("logout")
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
logout(@Body() dto: RefreshDto, @CurrentUser() user: any) {
return this.auth.logout(dto.refreshToken, user.id);
}
/** GET /api/auth/me — Perfil del usuario autenticado */
@Get("me")
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: any) {
return this.auth.getProfile(user.id);
}
/** POST /api/auth/mfa/setup — Genera QR para TOTP */
@Post("mfa/setup")
@UseGuards(JwtAuthGuard)
setupMfa(@CurrentUser() user: any) {
return this.auth.setupMfa(user.id);
}
/** POST /api/auth/mfa/verify — Activa MFA con primer código TOTP */
@Post("mfa/verify")
@UseGuards(JwtAuthGuard)
verifyMfa(@Body() dto: SetupMfaDto, @CurrentUser() user: any) {
return this.auth.verifyMfa(user.id, dto.totpCode);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>("JWT_SECRET", "change-me"),
signOptions: { expiresIn: config.get("JWT_EXPIRES_IN", "15m") },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+209
View File
@@ -0,0 +1,209 @@
import {
Injectable, UnauthorizedException, ConflictException, BadRequestException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../prisma/prisma.service";
import { generateSuiteCode } from "../common/utils/suite-code.util";
import { RegisterDto, LoginDto } from "./dto/auth.dto";
import * as bcrypt from "bcrypt";
import * as crypto from "crypto";
import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib";
const TENANT_SLUG = "moraworld";
const BCRYPT_ROUNDS = 10;
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwt: JwtService,
private config: ConfigService,
) {}
// ─── Register ────────────────────────────────────────────────
async register(dto: RegisterDto): Promise<any> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: TENANT_SLUG } });
if (!tenant) throw new BadRequestException("Tenant no encontrado.");
const existing = await this.prisma.client.user.findUnique({
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } },
});
if (existing) throw new ConflictException("Ya existe una cuenta con ese email.");
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
const user = await this.prisma.client.user.create({
data: {
tenantId: tenant.id,
email: dto.email.toLowerCase(),
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
phone: dto.phone ?? null,
role: "CLIENTE",
},
});
// Asignar Suite automáticamente (doc §09)
const suiteCount = await this.prisma.client.suite.count({ where: { tenantId: tenant.id } });
const suiteCode = generateSuiteCode(suiteCount + 1);
await this.prisma.client.suite.create({
data: { tenantId: tenant.id, userId: user.id, code: suiteCode },
});
await this.audit(tenant.id, user.id, "USER_REGISTER", "User", user.id);
const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id);
return {
user: this.sanitizeUser(user),
suiteCode,
suiteAddress: `150 N Day St, Suite ${suiteCode}, City of Orange, NJ 07050, EE.UU.`,
...tokens,
};
}
// ─── Login ───────────────────────────────────────────────────
async login(dto: LoginDto, ip?: string): Promise<any> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: TENANT_SLUG } });
if (!tenant) throw new UnauthorizedException();
const user = await this.prisma.client.user.findUnique({
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } },
});
if (!user || !user.isActive) {
await this.audit(tenant.id, null, "LOGIN_FAILED", "User", dto.email);
throw new UnauthorizedException("Credenciales inválidas.");
}
const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) {
await this.audit(tenant.id, user.id, "LOGIN_FAILED", "User", user.id);
throw new UnauthorizedException("Credenciales inválidas.");
}
// MFA
if (user.mfaEnabled) {
if (!dto.totpCode) return { requiresMfa: true, userId: user.id };
const ok = totpVerify({ token: dto.totpCode, secret: user.mfaSecret! });
if (!ok) {
await this.audit(tenant.id, user.id, "MFA_FAILED", "User", user.id);
throw new UnauthorizedException("Código MFA inválido.");
}
}
await this.prisma.client.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
await this.audit(tenant.id, user.id, "LOGIN_SUCCESS", "User", user.id);
const suite = await this.prisma.client.suite.findUnique({ where: { userId: user.id } });
const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id);
return {
user: this.sanitizeUser(user),
suite: suite ? {
code: suite.code,
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
} : null,
...tokens,
};
}
// ─── Refresh ─────────────────────────────────────────────────
async refresh(refreshToken: string): Promise<any> {
const stored = await this.prisma.client.refreshToken.findUnique({ where: { token: refreshToken } });
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException("Refresh token inválido o expirado.");
}
const user = await this.prisma.client.user.findUnique({ where: { id: stored.userId } });
if (!user || !user.isActive) throw new UnauthorizedException();
await this.prisma.client.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date() },
});
return this.generateTokens(user.id, user.email, user.role, user.tenantId);
}
// ─── Logout ──────────────────────────────────────────────────
async logout(refreshToken: string, userId: string): Promise<void> {
await this.prisma.client.refreshToken.updateMany({
where: { token: refreshToken, userId },
data: { revokedAt: new Date() },
});
await this.audit(null, userId, "LOGOUT", "User", userId);
}
// ─── MFA Setup ───────────────────────────────────────────────
async setupMfa(userId: string): Promise<any> {
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException();
const secret = generateSecret();
const otpAuthUrl = generateURI({
issuer: "Moraworld Imports",
label: user.email,
secret,
});
await this.prisma.client.user.update({
where: { id: userId },
data: { mfaSecret: secret, mfaEnabled: false },
});
return { secret, otpAuthUrl };
}
async verifyMfa(userId: string, totpCode: string): Promise<any> {
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
if (!user?.mfaSecret) throw new BadRequestException("Primero genera el secreto MFA.");
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: true } });
await this.audit(user.tenantId, userId, "MFA_ENABLED", "User", userId);
return { mfaEnabled: true };
}
// ─── Profile ─────────────────────────────────────────────────
async getProfile(userId: string): Promise<any> {
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException();
const suite = await this.prisma.client.suite.findUnique({ where: { userId } });
return {
...this.sanitizeUser(user),
suite: suite ? {
code: suite.code,
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
} : null,
};
}
// ─── Helpers ─────────────────────────────────────────────────
private async generateTokens(userId: string, email: string, role: string, tenantId: string): Promise<any> {
const payload = { sub: userId, email, role, tenantId };
const accessToken = this.jwt.sign(payload, { expiresIn: this.config.get("JWT_EXPIRES_IN", "15m") });
const refreshToken = crypto.randomBytes(64).toString("hex");
const refreshExpires = new Date();
refreshExpires.setDate(refreshExpires.getDate() + 7);
await this.prisma.client.refreshToken.create({
data: { userId, token: refreshToken, expiresAt: refreshExpires },
});
return { accessToken, refreshToken };
}
private sanitizeUser(user: any): any {
const { passwordHash, mfaSecret, ...safe } = user;
return safe;
}
private async audit(tenantId: string | null, userId: string | null, action: string, resource?: string, resourceId?: string): Promise<void> {
await this.prisma.client.auditLog.create({
data: { tenantId, userId, action, resource, resourceId },
});
}
}
@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest().user;
},
);
+44
View File
@@ -0,0 +1,44 @@
import { IsEmail, IsString, MinLength, IsOptional, Matches } from "class-validator";
export class RegisterDto {
@IsEmail({}, { message: "Email inválido" })
email!: string;
@IsString()
@MinLength(8)
password!: string;
@IsString()
@MinLength(2)
firstName!: string;
@IsString()
@MinLength(2)
lastName!: string;
@IsOptional()
@IsString()
phone?: string;
}
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
password!: string;
@IsOptional()
@IsString()
totpCode?: string;
}
export class RefreshDto {
@IsString()
refreshToken!: string;
}
export class SetupMfaDto {
@IsString()
totpCode!: string;
}
+31
View File
@@ -0,0 +1,31 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
export const ROLES_KEY = "roles";
export function Roles(...roles: string[]) {
return (target: any, key?: string, descriptor?: any) => {
Reflect.defineMetadata(ROLES_KEY, roles, descriptor?.value ?? target);
return descriptor ?? target;
};
}
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
ctx.getHandler(), ctx.getClass(),
]);
if (!required || required.length === 0) return true;
const { user } = ctx.switchToHttp().getRequest();
if (!user || !required.includes(user.role)) {
throw new ForbiddenException("No tienes permisos para esta acción.");
}
return true;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>("JWT_SECRET", "change-me"),
});
}
async validate(payload: { sub: string; email: string; role: string; tenantId: string }): Promise<any> {
const user = await this.prisma.client.user.findUnique({ where: { id: payload.sub } });
if (!user || !user.isActive) throw new UnauthorizedException();
return { id: user.id, email: user.email, role: user.role, tenantId: user.tenantId };
}
}
+34
View File
@@ -0,0 +1,34 @@
import { Controller, Get, Post, Patch, Body, Param, UseGuards, HttpCode, HttpStatus } from "@nestjs/common";
import { B2BService, CreateB2BDto, UpdateB2BStatusDto } from "./b2b.service";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
const TENANT_FALLBACK = "moraworld"; // B2B público usa tenantId del slug
@Controller("b2b")
export class B2BController {
constructor(private svc: B2BService) {}
/** POST /api/b2b — público */
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() dto: CreateB2BDto, @CurrentUser() user: any): Promise<any> {
// Si hay usuario autenticado usa su tenantId, sino carga el tenant por slug
const tenantId = user?.tenantId ?? TENANT_FALLBACK;
return this.svc.create(dto, tenantId);
}
@Get()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
findAll(@CurrentUser() user: any): Promise<any[]> {
return this.svc.findAll(user.tenantId);
}
@Patch(":id/status")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
updateStatus(@Param("id") id: string, @Body() dto: UpdateB2BStatusDto): Promise<any> {
return this.svc.updateStatus(id, dto);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Module } from "@nestjs/common";
import { B2BController } from "./b2b.controller";
import { B2BService } from "./b2b.service";
@Module({ controllers: [B2BController], providers: [B2BService] })
export class B2BModule {}
+64
View File
@@ -0,0 +1,64 @@
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { generateTrackingId } from "../common/utils/tracking-id.util";
export class CreateB2BDto {
@IsString() contactName!: string;
@IsString() contactEmail!: string;
@IsOptional() @IsString() contactPhone?: string;
@IsOptional() @IsString() companyName?: string;
@IsString() merchandiseType!: string;
@IsString() description!: string;
@IsOptional() @IsNumber() @Min(0) commercialValue?: number;
}
export class UpdateB2BStatusDto {
@IsString() status!: string;
@IsOptional() @IsString() quotationNotes?: string;
@IsOptional() @IsNumber() @Min(0) quotationAmount?: number;
}
@Injectable()
export class B2BService {
constructor(private prisma: PrismaService) {}
async findAll(tenantId: string): Promise<any[]> {
return this.prisma.client.b2BRequest.findMany({
where: { tenantId },
orderBy: { createdAt: "desc" },
});
}
async create(dto: CreateB2BDto, tenantId: string): Promise<any> {
const count = await this.prisma.client.b2BRequest.count();
const trackingId = `B2B-${String(count + 1).padStart(6, "0")}`;
return this.prisma.client.b2BRequest.create({
data: {
tenantId,
trackingId,
contactName: dto.contactName,
contactEmail: dto.contactEmail,
contactPhone: dto.contactPhone,
companyName: dto.companyName,
merchandiseType: dto.merchandiseType,
description: dto.description,
commercialValue: dto.commercialValue ?? null,
status: "PENDIENTE",
},
});
}
async updateStatus(id: string, dto: UpdateB2BStatusDto): Promise<any> {
const req = await this.prisma.client.b2BRequest.findUnique({ where: { id } });
if (!req) throw new NotFoundException("Solicitud B2B no encontrada.");
return this.prisma.client.b2BRequest.update({
where: { id },
data: {
status: dto.status as any,
quotationNotes: dto.quotationNotes,
quotationAmount: dto.quotationAmount ?? null,
},
});
}
}
+11
View File
@@ -1,4 +1,5 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module";
async function bootstrap() {
@@ -15,6 +16,16 @@ async function bootstrap() {
app.setGlobalPrefix("api");
// Validación global de DTOs (class-validator)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: false,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
const port = process.env.API_PORT ?? process.env.PORT ?? 3001;
await app.listen(port, "0.0.0.0");
+50
View File
@@ -0,0 +1,50 @@
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
export class CreatePackageDto {
@IsString()
vendorTracking!: string;
@IsString()
description!: string;
@IsString()
store!: string;
@IsOptional()
@IsNumber()
@Min(0)
declaredValue?: number;
@IsOptional()
@IsNumber()
@Min(0)
declaredWeightLb?: number;
@IsOptional()
@IsNumber()
@Min(0)
lengthCm?: number;
@IsOptional()
@IsNumber()
@Min(0)
widthCm?: number;
@IsOptional()
@IsNumber()
@Min(0)
heightCm?: number;
/** userId del cliente al que pertenece este paquete */
@IsString()
userId!: string;
}
export class UpdateStatusDto {
@IsString()
status!: string;
@IsOptional()
@IsString()
note?: string;
}
@@ -0,0 +1,33 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from "@nestjs/common";
import { PackagesService } from "./packages.service";
import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("packages")
@UseGuards(JwtAuthGuard, RolesGuard)
export class PackagesController {
constructor(private svc: PackagesService) {}
@Get()
findAll(@CurrentUser() user: any, @Query("status") status?: string, @Query("search") search?: string): Promise<any[]> {
return this.svc.findAll(user, { status, search });
}
@Get(":id")
findOne(@Param("id") id: string, @CurrentUser() user: any): Promise<any> {
return this.svc.findOne(id, user);
}
@Post()
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
create(@Body() dto: CreatePackageDto, @CurrentUser() user: any): Promise<any> {
return this.svc.create(dto, user.id, user.tenantId);
}
@Patch(":id/status")
@Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
updateStatus(@Param("id") id: string, @Body() dto: UpdateStatusDto, @CurrentUser() user: any): Promise<any> {
return this.svc.updateStatus(id, dto, user.id);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { PackagesController } from "./packages.controller";
import { PackagesService } from "./packages.service";
@Module({
controllers: [PackagesController],
providers: [PackagesService],
})
export class PackagesModule {}
+102
View File
@@ -0,0 +1,102 @@
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { generateTrackingId } from "../common/utils/tracking-id.util";
import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto";
@Injectable()
export class PackagesService {
constructor(private prisma: PrismaService) {}
async findAll(user: any, filters?: { status?: string; search?: string }): Promise<any[]> {
const where: any = { tenantId: user.tenantId };
if (user.role === "CLIENTE") {
where.userId = user.id;
}
if (filters?.status) where.status = filters.status;
if (filters?.search) {
where.OR = [
{ trackingId: { contains: filters.search, mode: "insensitive" } },
{ vendorTracking: { contains: filters.search, mode: "insensitive" } },
{ description: { contains: filters.search, mode: "insensitive" } },
];
}
return this.prisma.client.package.findMany({
where,
include: {
user: { select: { firstName: true, lastName: true, email: true } },
statusHistory: { orderBy: { createdAt: "desc" }, take: 1 },
},
orderBy: { createdAt: "desc" },
});
}
async findOne(id: string, user: any): Promise<any> {
const pkg = await this.prisma.client.package.findUnique({
where: { id },
include: {
user: { select: { firstName: true, lastName: true, email: true } },
statusHistory: { orderBy: { createdAt: "desc" } },
preAlert: true,
},
});
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
if (user.role === "CLIENTE" && pkg.userId !== user.id) throw new ForbiddenException();
return pkg;
}
async create(dto: CreatePackageDto, operatorId: string, tenantId: string): Promise<any> {
const trackingId = generateTrackingId();
const pkg = await this.prisma.client.package.create({
data: {
trackingId,
tenantId,
userId: dto.userId,
description: dto.description,
store: dto.store,
vendorTracking: dto.vendorTracking,
declaredValue: dto.declaredValue ?? 0,
declaredWeight: dto.declaredWeightLb ?? null,
lengthCm: dto.lengthCm ?? null,
widthCm: dto.widthCm ?? null,
heightCm: dto.heightCm ?? null,
status: "REGISTRADO",
},
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: pkg.id,
status: "REGISTRADO",
createdBy: operatorId,
note: "Paquete registrado al recibirse en bodega NJ",
},
});
return pkg;
}
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.");
const updated = await this.prisma.client.package.update({
where: { id },
data: { status: dto.status as any },
});
await this.prisma.client.packageStatusHistory.create({
data: {
packageId: id,
status: dto.status as any,
createdBy: operatorId,
note: dto.note,
},
});
return updated;
}
}
@@ -0,0 +1,23 @@
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
export class CreatePreAlertDto {
@IsString()
store!: string;
@IsString()
description!: string;
@IsOptional()
@IsNumber()
@Min(0)
declaredValue?: number;
@IsOptional()
@IsString()
vendorTracking?: string;
}
export class UpdatePreAlertStatusDto {
@IsString()
status!: string;
}
@@ -0,0 +1,33 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from "@nestjs/common";
import { PreAlertsService } from "./pre-alerts.service";
import { CreatePreAlertDto, UpdatePreAlertStatusDto } from "./dto/pre-alert.dto";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("pre-alerts")
@UseGuards(JwtAuthGuard, RolesGuard)
export class PreAlertsController {
constructor(private svc: PreAlertsService) {}
@Get()
findAll(@CurrentUser() user: any) {
return this.svc.findAll(user);
}
@Post()
@Roles("CLIENTE")
create(@Body() dto: CreatePreAlertDto, @CurrentUser() user: any) {
return this.svc.create(dto, user);
}
@Patch(":id/status")
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
updateStatus(@Param("id") id: string, @Body() dto: UpdatePreAlertStatusDto): Promise<any> {
return this.svc.updateStatus(id, dto);
}
@Delete(":id")
remove(@Param("id") id: string, @CurrentUser() user: any) {
return this.svc.remove(id, user);
}
}
@@ -0,0 +1,6 @@
import { Module } from "@nestjs/common";
import { PreAlertsController } from "./pre-alerts.controller";
import { PreAlertsService } from "./pre-alerts.service";
@Module({ controllers: [PreAlertsController], providers: [PreAlertsService] })
export class PreAlertsModule {}
@@ -0,0 +1,45 @@
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { CreatePreAlertDto, UpdatePreAlertStatusDto } from "./dto/pre-alert.dto";
@Injectable()
export class PreAlertsService {
constructor(private prisma: PrismaService) {}
async findAll(user: any): Promise<any[]> {
const where: any = { tenantId: user.tenantId };
if (user.role === "CLIENTE") where.userId = user.id;
return this.prisma.client.preAlert.findMany({
where,
include: { user: { select: { firstName: true, lastName: true, email: true } } },
orderBy: { createdAt: "desc" },
});
}
async create(dto: CreatePreAlertDto, user: any): Promise<any> {
return this.prisma.client.preAlert.create({
data: {
tenantId: user.tenantId,
userId: user.id,
store: dto.store,
description: dto.description,
declaredValue: dto.declaredValue ?? 0,
vendorTracking: dto.vendorTracking,
status: "PENDIENTE",
},
});
}
async updateStatus(id: string, dto: UpdatePreAlertStatusDto): Promise<any> {
const alert = await this.prisma.client.preAlert.findUnique({ where: { id } });
if (!alert) throw new NotFoundException("Pre-alerta no encontrada.");
return this.prisma.client.preAlert.update({ where: { id }, data: { status: dto.status as any } });
}
async remove(id: string, user: any): Promise<any> {
const alert = await this.prisma.client.preAlert.findUnique({ where: { id } });
if (!alert) throw new NotFoundException("Pre-alerta no encontrada.");
if (user.role === "CLIENTE" && alert.userId !== user.id) throw new ForbiddenException();
return this.prisma.client.preAlert.delete({ where: { id } });
}
}
+43
View File
@@ -0,0 +1,43 @@
import { Controller, Get, Patch, Body, Param, Query, UseGuards } from "@nestjs/common";
import { UsersService } from "./users.service";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import { IsEnum, IsBoolean } from "class-validator";
class UpdateRoleDto {
@IsEnum(["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"])
role!: string;
}
class SetActiveDto {
@IsBoolean() isActive!: boolean;
}
@Controller("users")
@UseGuards(JwtAuthGuard, RolesGuard)
export class UsersController {
constructor(private svc: UsersService) {}
@Get()
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
findAll(@CurrentUser() user: any, @Query("search") search?: string): Promise<any[]> {
return this.svc.findAll(user.tenantId, search);
}
@Get(":id")
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
findOne(@Param("id") id: string): Promise<any> {
return this.svc.findOne(id);
}
@Patch(":id/role")
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
updateRole(@Param("id") id: string, @Body() dto: UpdateRoleDto): Promise<any> {
return this.svc.updateRole(id, dto.role);
}
@Patch(":id/active")
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
setActive(@Param("id") id: string, @Body() dto: SetActiveDto): Promise<any> {
return this.svc.setActive(id, dto.isActive);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({ controllers: [UsersController], providers: [UsersService] })
export class UsersModule {}
+49
View File
@@ -0,0 +1,49 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async findAll(tenantId: string, search?: string): Promise<any[]> {
return this.prisma.client.user.findMany({
where: {
tenantId,
...(search ? {
OR: [
{ email: { contains: search, mode: "insensitive" } },
{ firstName: { contains: search, mode: "insensitive" } },
{ lastName: { contains: search, mode: "insensitive" } },
],
} : {}),
},
select: {
id: true, email: true, firstName: true, lastName: true, phone: true,
role: true, isActive: true, mfaEnabled: true, lastLoginAt: true, createdAt: true,
suite: { select: { code: true } },
},
orderBy: { createdAt: "desc" },
});
}
async findOne(id: string): Promise<any> {
const user = await this.prisma.client.user.findUnique({
where: { id },
select: {
id: true, email: true, firstName: true, lastName: true, phone: true,
role: true, isActive: true, mfaEnabled: true, lastLoginAt: true, createdAt: true,
suite: { select: { code: true } },
},
});
if (!user) throw new NotFoundException("Usuario no encontrado.");
return user;
}
async updateRole(id: string, role: string): Promise<any> {
return this.prisma.client.user.update({ where: { id }, data: { role: role as any } });
}
async setActive(id: string, isActive: boolean): Promise<any> {
return this.prisma.client.user.update({ where: { id }, data: { isActive } });
}
}
+2
View File
@@ -9,11 +9,13 @@
"lint": "next lint"
},
"dependencies": {
"js-cookie": "^3.0.8",
"next": "^15.3.2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.15.21",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
+20
View File
@@ -0,0 +1,20 @@
"use client";
// Auditoría — placeholder con nota de implementación futura (requiere endpoint /audit)
export default function AuditoriaPage() {
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Auditoría</h1><p className="dash-page-subtitle">Registro de acciones del sistema (AuditLog).</p></div>
<div className="card">
<div className="card-body">
<div className="alert alert-info">
El log de auditoría se registra automáticamente en la tabla <code>AuditLog</code> de la base de datos.
Para consultarlo directamente, accede al panel de base de datos o agrega el endpoint <code>GET /api/audit</code> en la API.
</div>
<p style={{ marginTop: "1rem", fontSize: ".9rem", color: "var(--gray-500)" }}>
Acciones registradas: LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, MFA_ENABLED, USER_REGISTER, y más.
</p>
</div>
</div>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/admin", icon: "◈", label: "Dashboard" },
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
{ href: "/bodega", icon: "📦", label: "→ Bodega" },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
if (!["ADMIN_EMPRESA","SUPER_ADMIN"].includes(u.role)) { router.replace("/portal"); return; }
setUser(u);
}, [router]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
clearAuth(); router.push("/login");
};
if (!user) return <div className="loading-overlay"><div className="spinner" /></div>;
return (
<div className="dash-layout">
<aside className="dash-sidebar">
<div className="dash-logo" style={{ color: "var(--accent)" }}>Admin<span style={{ color: "white" }}>Panel</span></div>
<nav className="dash-nav">
{NAV.map(item => (
<Link key={item.href} href={item.href}
className={`dash-nav-item ${pathname.startsWith(item.href) && item.href !== "/admin" ? "active" : pathname === item.href ? "active" : ""}`}>
<span>{item.icon}</span><span>{item.label}</span>
</Link>
))}
</nav>
<div className="dash-user">
<div className="dash-user-name">{user.firstName} {user.lastName}</div>
<div className="dash-user-role" style={{ color: "var(--accent)" }}>{user.role}</div>
<button className="btn btn-ghost btn-sm" style={{ marginTop: ".5rem", color: "rgba(255,255,255,.5)", fontSize: ".8rem" }} onClick={handleLogout}>Cerrar sesión</button>
</div>
</aside>
<div className="dash-main">
<header className="dash-topbar">
<span style={{ fontSize: "1rem", fontWeight: 600 }}>Panel de Administración</span>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{user.email}</span>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function AdminDashboard() {
const [users, setUsers] = useState<any[]>([]);
const [b2b, setB2b] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.users.list(), api.b2b.list()])
.then(([u, b]) => { setUsers(u); setB2b(b); })
.catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
const clientes = users.filter(u => u.role === "CLIENTE").length;
const b2bPending = b2b.filter(r => r.status === "PENDIENTE").length;
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Dashboard Admin</h1></div>
<div className="grid-4" style={{ marginBottom: "2rem" }}>
{[
{ label: "Total usuarios", value: users.length, color: "var(--primary)" },
{ label: "Clientes", value: clientes, color: "var(--green)" },
{ label: "Solicitudes B2B", value: b2bPending, color: "var(--yellow)" },
{ label: "Activos", value: users.filter(u=>u.isActive).length, color: "var(--accent)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Últimos usuarios</span>
<Link href="/admin/usuarios" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
</div>
<div className="table-wrap" style={{ borderRadius: 0, border: "none" }}>
<table>
<thead><tr><th>Nombre</th><th>Email</th><th>Rol</th><th>Estado</th></tr></thead>
<tbody>
{users.slice(0,8).map(u => (
<tr key={u.id}>
<td className="font-medium">{u.firstName} {u.lastName}</td>
<td className="text-sm text-muted">{u.email}</td>
<td><span className="badge badge-blue">{u.role}</span></td>
<td><span className={`badge ${u.isActive ? "badge-green" : "badge-gray"}`}>{u.isActive ? "Activo" : "Inactivo"}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Solicitudes B2B pendientes</span></div>
{b2bPending === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)" }}>Sin solicitudes pendientes.</p>
) : (
b2b.filter(r=>r.status==="PENDIENTE").map(r => (
<div key={r.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600 }}>{r.companyName}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{r.contactEmail}</div>
</div>
))
)}
</div>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function ReportesPage() {
const [users, setUsers] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.users.list(), api.packages.list()])
.then(([u, p]) => { setUsers(u); setPackages(p); })
.catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
const byStatus: Record<string, number> = {};
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
const totalDeclared = packages.reduce((a, p) => a + (p.declaredValueUsd ?? 0), 0);
const byRole: Record<string, number> = {};
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Reportes</h1><p className="dash-page-subtitle">Resumen operativo del sistema.</p></div>
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
{ label: "Entregados", value: byStatus["ENTREGADO"] ?? 0, color: "var(--green)" },
{ label: "En tránsito", value: byStatus["EN_CAMINO_A_ECUADOR"] ?? 0, color: "var(--yellow)" },
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Paquetes por estado</span></div>
<div className="card-body">
{Object.entries(byStatus).map(([status, count]) => (
<div key={status} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ fontSize: ".9rem" }}>{status.replace(/_/g," ")}</span>
<span style={{ fontWeight: 700 }}>{count}</span>
</div>
))}
{Object.keys(byStatus).length === 0 && <p style={{ color: "var(--gray-500)" }}>Sin datos.</p>}
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Usuarios por rol</span></div>
<div className="card-body">
{Object.entries(byRole).map(([role, count]) => (
<div key={role} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ fontSize: ".9rem" }}>{role}</span>
<span style={{ fontWeight: 700 }}>{count}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
// Página de tarifas — muestra tabla estática de precios (§05 doc) + formulario de edición futuro
const TARIFAS = [
{ category: "Mensajería Acelerada", max: "$200", exento: "Sí (≤ $200)", flete: "$8$15/lb", notas: "Hasta 4 kg · sin impuestos" },
{ category: "Courier", max: "$400", exento: "≤ $200", flete: "$8$15/lb", notas: "Impuestos desde $200.01" },
{ category: "Régimen 4×4", max: "$2,000",exento: "No", flete: "Variable", notas: "SENAE arancel + IVA + FODINFA" },
{ category: "Carga pesada (FCL)", max: "Ilimitado", exento: "No", flete: "Cotización", notas: "Tarifa por m³ y peso" },
];
export default function TarifasPage() {
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Tarifas</h1>
<p className="dash-page-subtitle">Tabla de regímenes aduaneros y precios base.</p>
</div>
<div className="card" style={{ marginBottom: "1.5rem" }}>
<div className="card-header"><span className="font-semibold">Regímenes aduaneros (§05 / §15)</span></div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead>
<tr>
<th>Régimen</th><th>Valor máx.</th><th>Exento</th><th>Flete</th><th>Notas</th>
</tr>
</thead>
<tbody>
{TARIFAS.map(t => (
<tr key={t.category}>
<td className="font-semibold">{t.category}</td>
<td>{t.max}</td>
<td><span className={`badge ${t.exento.startsWith("Sí") || t.exento.startsWith("≤") ? "badge-green" : "badge-gray"}`}>{t.exento}</span></td>
<td>{t.flete}</td>
<td className="text-sm text-muted">{t.notas}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="alert alert-info">
<strong>Nota:</strong> Las tarifas de flete varían según el peso volumétrico (L×W×H / 139) vs. peso real. Se cobra el mayor. Los impuestos SENAE (FODINFA 0.5%, Arancel variable, IVA 15%) se calculan automáticamente en la calculadora del portal.
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const ROLES = ["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"];
export default function UsuariosPage() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [updating, setUpdating] = useState<string | null>(null);
const load = (s?: string) => { setLoading(true); api.users.list(s).then(setUsers).catch(()=>{}).finally(()=>setLoading(false)); };
useEffect(() => { load(); }, []);
const handleRoleChange = async (id: string, role: string) => {
setUpdating(id);
try { await api.users.updateRole(id, role); load(); } catch {}
finally { setUpdating(null); }
};
const handleToggleActive = async (id: string, isActive: boolean) => {
setUpdating(id);
try { await api.users.setActive(id, !isActive); load(); } catch {}
finally { setUpdating(null); }
};
return (
<div>
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
<div><h1 className="dash-page-title">Usuarios</h1><p className="dash-page-subtitle">Gestión de cuentas y roles.</p></div>
<div style={{ display: "flex", gap: ".75rem" }}>
<input className="input" style={{ maxWidth: 260 }} placeholder="Buscar por nombre o email…"
value={search} onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === "Enter" && load(search)} />
<button className="btn btn-primary" onClick={() => load(search)}>Buscar</button>
</div>
</div>
<div className="card">
<div className="table-wrap" style={{ borderRadius: "var(--radius-lg)", border: "none" }}>
{loading ? <div style={{ padding: "3rem", textAlign: "center" }}><div className="spinner mx-auto" /></div> : (
<table>
<thead><tr><th>Nombre</th><th>Email</th><th>Casillero</th><th>Rol</th><th>Estado</th><th>Acciones</th></tr></thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td className="font-medium">{u.firstName} {u.lastName}</td>
<td className="text-sm">{u.email}</td>
<td><span className="code-box">{u.suite?.code ?? "—"}</span></td>
<td>
<select className="select" style={{ padding: ".2rem .5rem", fontSize: ".8rem", width: "auto" }}
value={u.role} disabled={updating === u.id}
onChange={e => handleRoleChange(u.id, e.target.value)}>
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</td>
<td><span className={`badge ${u.isActive ? "badge-green" : "badge-red"}`}>{u.isActive ? "Activo" : "Inactivo"}</span></td>
<td>
<button
className={`btn btn-sm ${u.isActive ? "btn-danger" : "btn-success"}`}
disabled={updating === u.id}
onClick={() => handleToggleActive(u.id, u.isActive)}
>
{u.isActive ? "Desactivar" : "Activar"}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// Despacho — paquetes listos para enviar a Ecuador
export default function DespachoPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [msg, setMsg] = useState("");
const load = () => {
api.packages.list({ status: "LISTO_PARA_RETIRO" }).then(setPackages).catch(()=>{}).finally(()=>setLoading(false));
};
useEffect(() => { load(); }, []);
const markDispatched = async (id: string) => {
setUpdating(id);
try {
await api.packages.updateStatus(id, { status: "EN_CAMINO_A_ECUADOR", notes: "Despachado desde bodega NJ" });
setMsg("Paquete marcado como despachado.");
load();
} catch (err: any) { setMsg(err.message ?? "Error"); }
finally { setUpdating(null); }
};
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Despacho</h1><p className="dash-page-subtitle">Paquetes listos para envío a Ecuador.</p></div>
{msg && <div className="alert alert-success mb-4">{msg}</div>}
{loading ? <div className="flex justify-center py-16"><div className="spinner" /></div> : (
packages.length === 0 ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}></div>
<p style={{ color: "var(--gray-500)" }}>No hay paquetes pendientes de despacho.</p>
</div>
) : (
<div className="card">
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
<table>
<thead><tr><th>Tracking</th><th>Cliente</th><th>Peso</th><th>Valor declarado</th><th>Categoría</th><th>Acción</th></tr></thead>
<tbody>
{packages.map(p => (
<tr key={p.id}>
<td className="font-semibold text-primary">{p.trackingId}</td>
<td>{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
<td>{p.weightLb ? `${p.weightLb} lb` : "—"}</td>
<td>{p.declaredValueUsd ? `$${p.declaredValueUsd}` : "—"}</td>
<td><span className="badge badge-blue">{(p.senaeCategory ?? "").replace(/_/g," ")}</span></td>
<td>
<button className="btn btn-primary btn-sm" disabled={updating === p.id}
onClick={() => markDispatched(p.id)}>
{updating === p.id ? "…" : "Despachar →"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/bodega", icon: "◈", label: "Dashboard" },
{ href: "/bodega/paquetes", icon: "📦", label: "Paquetes" },
{ href: "/bodega/verificacion", icon: "✅", label: "Verificación NJ" },
{ href: "/bodega/despacho", icon: "🚢", label: "Despacho" },
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
];
const ALLOWED = ["OPERADOR_BODEGA","AGENTE_ADUANAS","ADMIN","SUPERADMIN"];
export default function BodegaLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
if (!ALLOWED.includes(u.role)) { router.replace("/portal"); return; }
setUser(u);
}, [router]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
clearAuth(); router.push("/login");
};
if (!user) return <div className="loading-overlay"><div className="spinner" /></div>;
return (
<div className="dash-layout">
<aside className="dash-sidebar">
<div className="dash-logo" style={{ color: "var(--green)" }}>Bodega<span style={{ color: "white" }}>NJ</span></div>
<nav className="dash-nav">
{NAV.map(item => (
<Link key={item.href} href={item.href}
className={`dash-nav-item ${pathname === item.href ? "active" : ""}`}>
<span>{item.icon}</span><span>{item.label}</span>
</Link>
))}
</nav>
<div className="dash-user">
<div className="dash-user-name">{user.firstName} {user.lastName}</div>
<div className="dash-user-role" style={{ color: "var(--green)" }}>{user.role}</div>
<button className="btn btn-ghost btn-sm" style={{ marginTop: ".5rem", color: "rgba(255,255,255,.5)", fontSize: ".8rem" }} onClick={handleLogout}>Cerrar sesión</button>
</div>
</aside>
<div className="dash-main">
<header className="dash-topbar">
<span style={{ fontSize: "1rem", fontWeight: 600 }}>Panel de Bodega</span>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{user.email}</span>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function BodegaDashboard() {
const [packages, setPackages] = useState<any[]>([]);
const [preAlerts, setPreAlerts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.packages.list(), api.preAlerts.list()])
.then(([p, a]) => { setPackages(p); setPreAlerts(a); })
.catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
const recibidos = packages.filter(p => p.status === "RECIBIDO_EN_NJ").length;
const enCamino = packages.filter(p => p.status === "EN_CAMINO_A_ECUADOR").length;
const listos = packages.filter(p => p.status === "LISTO_PARA_RETIRO").length;
const alertasPend = preAlerts.filter(p => p.status === "PENDIENTE").length;
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Dashboard Bodega</h1></div>
<div className="grid-4" style={{ marginBottom: "2rem" }}>
{[
{ label: "Recibidos en NJ", value: recibidos, color: "var(--primary)" },
{ label: "En camino a EC", value: enCamino, color: "var(--yellow)" },
{ label: "Listos para retiro",value: listos, color: "var(--green)" },
{ label: "Pre-alertas pend.", value: alertasPend, color: "var(--accent)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Paquetes recientes</span>
<Link href="/bodega/paquetes" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
</div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead><tr><th>Tracking</th><th>Cliente</th><th>Estado</th></tr></thead>
<tbody>
{packages.slice(0,8).map(p => (
<tr key={p.id}>
<td className="font-semibold text-primary">{p.trackingId}</td>
<td className="text-sm">{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
<td><span className="badge badge-blue">{p.status.replace(/_/g," ")}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Pre-alertas pendientes</span></div>
{alertasPend === 0 ? <p style={{ padding: "1.5rem", color: "var(--gray-500)" }}>Sin pre-alertas pendientes.</p> : (
preAlerts.filter(a => a.status === "PENDIENTE").map(a => (
<div key={a.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600 }}>{a.store} {a.orderNumber}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{a.description ?? "Sin descripción"}</div>
</div>
))
)}
</div>
</div>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const STATUSES = ["RECIBIDO_EN_NJ","EN_PROCESO","EN_CAMINO_A_ECUADOR","EN_ADUANA","EN_BODEGA_EC","LISTO_PARA_RETIRO","ENTREGADO","RETENIDO_ADUANA","DEVUELTO","PERDIDO","CANCELADO"];
export default function BodegaPaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<any | null>(null);
const [statusForm, setStatusForm] = useState({ status: "", notes: "", location: "" });
const [updating, setUpdating] = useState(false);
const [msg, setMsg] = useState("");
// Crear paquete
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({ trackingNumber: "", suiteCode: "", description: "", weightLb: "", declaredValueUsd: "" });
const [suites, setSuites] = useState<any[]>([]);
const [creating, setCreating] = useState(false);
const load = () => { setLoading(true); api.packages.list().then(setPackages).catch(()=>{}).finally(()=>setLoading(false)); };
useEffect(() => { load(); }, []);
const handleUpdateStatus = async (e: React.FormEvent) => {
e.preventDefault(); setUpdating(true); setMsg("");
try {
await api.packages.updateStatus(selected.id, { status: statusForm.status, notes: statusForm.notes, location: statusForm.location });
setMsg("Estado actualizado."); load(); setSelected(null);
} catch (err: any) { setMsg(err.message ?? "Error"); }
finally { setUpdating(false); }
};
return (
<div>
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
<div><h1 className="dash-page-title">Paquetes</h1><p className="dash-page-subtitle">Gestión de paquetes en bodega.</p></div>
<button className="btn btn-primary" onClick={() => setShowCreate(!showCreate)}>+ Registrar paquete</button>
</div>
{msg && <div className="alert alert-success mb-4">{msg}</div>}
{/* Modal crear */}
{showCreate && (
<div className="card" style={{ marginBottom: "1.5rem" }}>
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Registrar nuevo paquete</span>
<button className="btn btn-ghost btn-sm" onClick={() => setShowCreate(false)}></button>
</div>
<div className="card-body">
<p className="text-sm text-muted mb-4">Ingresa el tracking del proveedor y el código de suite del cliente para registrar el paquete al recibirlo en NJ.</p>
<form style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: "1rem" }}
onSubmit={async e => {
e.preventDefault(); setCreating(true);
try {
// Buscar suiteId por código
const allPkgs = await api.packages.list();
const suiteMatch = allPkgs.find((p: any) => p.suite?.code === createForm.suiteCode);
// Si no hay coincidencia, mostrar error
if (!suiteMatch && !createForm.suiteCode) { setMsg("Código de suite inválido."); setCreating(false); return; }
await api.packages.create({
trackingNumber: createForm.trackingNumber,
description: createForm.description || undefined,
weightLb: createForm.weightLb ? parseFloat(createForm.weightLb) : undefined,
declaredValueUsd: createForm.declaredValueUsd ? parseFloat(createForm.declaredValueUsd) : undefined,
suiteId: suiteMatch?.suiteId ?? createForm.suiteCode, // API acepta suiteId
});
setMsg("Paquete registrado."); setShowCreate(false); load();
} catch (err: any) { setMsg(err.message); } finally { setCreating(false); }
}}>
<div className="form-group">
<label className="label">Tracking del proveedor *</label>
<input className="input" placeholder="1Z999AA…" value={createForm.trackingNumber}
onChange={e => setCreateForm(f => ({...f, trackingNumber: e.target.value}))} required />
</div>
<div className="form-group">
<label className="label">Suite del cliente (suiteId) *</label>
<input className="input" placeholder="ID del suite" value={createForm.suiteCode}
onChange={e => setCreateForm(f => ({...f, suiteCode: e.target.value}))} required />
</div>
<div className="form-group">
<label className="label">Descripción</label>
<input className="input" placeholder="Producto…" value={createForm.description}
onChange={e => setCreateForm(f => ({...f, description: e.target.value}))} />
</div>
<div className="form-group">
<label className="label">Peso (lb)</label>
<input type="number" className="input" placeholder="2.5" value={createForm.weightLb}
onChange={e => setCreateForm(f => ({...f, weightLb: e.target.value}))} />
</div>
<div className="form-group" style={{ gridColumn: "span 2", textAlign: "right" }}>
<button type="submit" className="btn btn-primary" disabled={creating}>{creating ? "Guardando…" : "Registrar"}</button>
</div>
</form>
</div>
</div>
)}
{/* Tabla */}
<div className="card">
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
{loading ? <div style={{ padding: "3rem", textAlign: "center" }}><div className="spinner mx-auto" /></div> : (
<table>
<thead><tr><th>Tracking</th><th>Proveedor</th><th>Cliente</th><th>Peso</th><th>Estado</th><th>Acción</th></tr></thead>
<tbody>
{packages.map(p => (
<tr key={p.id}>
<td className="font-semibold text-primary">{p.trackingId}</td>
<td className="text-sm">{p.trackingNumber ?? "—"}</td>
<td className="text-sm">{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
<td className="text-sm">{p.weightLb ? `${p.weightLb} lb` : "—"}</td>
<td><span className="badge badge-blue">{p.status.replace(/_/g," ")}</span></td>
<td>
<button className="btn btn-outline btn-sm"
onClick={() => { setSelected(p); setStatusForm({ status: p.status, notes: "", location: "" }); }}>
Actualizar
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Panel actualizar estado */}
{selected && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,.5)", zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}>
<div className="card" style={{ width: "100%", maxWidth: 480 }}>
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Actualizar estado {selected.trackingId}</span>
<button className="btn btn-ghost btn-sm" onClick={() => setSelected(null)}></button>
</div>
<div className="card-body">
<form onSubmit={handleUpdateStatus} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="label">Nuevo estado</label>
<select className="select" value={statusForm.status}
onChange={e => setStatusForm(f => ({...f, status: e.target.value}))}>
{STATUSES.map(s => <option key={s} value={s}>{s.replace(/_/g," ")}</option>)}
</select>
</div>
<div className="form-group">
<label className="label">Ubicación</label>
<input className="input" placeholder="Bodega NJ / Aeropuerto Guayaquil…" value={statusForm.location}
onChange={e => setStatusForm(f => ({...f, location: e.target.value}))} />
</div>
<div className="form-group">
<label className="label">Notas</label>
<textarea className="textarea" style={{ minHeight: 70 }} placeholder="Observaciones…" value={statusForm.notes}
onChange={e => setStatusForm(f => ({...f, notes: e.target.value}))} />
</div>
<button type="submit" className="btn btn-primary" disabled={updating}>{updating ? "Guardando…" : "Guardar estado"}</button>
</form>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// Verificación NJ — muestra pre-alertas pendientes para vincular con paquetes recibidos
export default function VerificacionPage() {
const [alerts, setAlerts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [msg, setMsg] = useState("");
const load = () => { api.preAlerts.list().then(setAlerts).catch(()=>{}).finally(()=>setLoading(false)); };
useEffect(() => { load(); }, []);
const handleStatus = async (id: string, status: string) => {
setUpdating(id);
try { await api.preAlerts.updateStatus(id, { status }); setMsg(`Pre-alerta marcada como ${status}.`); load(); }
catch (err: any) { setMsg(err.message ?? "Error"); }
finally { setUpdating(null); }
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow", RECIBIDO: "badge-blue", VINCULADO: "badge-green", RECHAZADO: "badge-red",
};
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Verificación NJ</h1><p className="dash-page-subtitle">Vincula las pre-alertas de clientes con paquetes recibidos.</p></div>
{msg && <div className="alert alert-success mb-4">{msg}</div>}
{loading ? <div className="flex justify-center py-16"><div className="spinner" /></div> : (
<div className="card">
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
<table>
<thead><tr><th>Tienda</th><th>Orden</th><th>Descripción</th><th>Valor est.</th><th>Tracking proveedor</th><th>Estado</th><th>Acciones</th></tr></thead>
<tbody>
{alerts.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: "center", padding: "2rem", color: "var(--gray-500)" }}>Sin pre-alertas.</td></tr>
) : alerts.map(a => (
<tr key={a.id}>
<td className="font-semibold">{a.store}</td>
<td className="text-sm">{a.orderNumber}</td>
<td className="text-sm">{a.description ?? "—"}</td>
<td className="text-sm">{a.estimatedValueUsd ? `$${a.estimatedValueUsd}` : "—"}</td>
<td className="text-sm">{a.trackingNumber ?? "—"}</td>
<td><span className={`badge ${STATUS_BADGE[a.status] ?? "badge-gray"}`}>{a.status}</span></td>
<td>
<div style={{ display: "flex", gap: ".5rem" }}>
{a.status === "PENDIENTE" && (
<>
<button className="btn btn-success btn-sm" disabled={updating === a.id}
onClick={() => handleStatus(a.id, "RECIBIDO")}>Recibido</button>
<button className="btn btn-danger btn-sm" disabled={updating === a.id}
onClick={() => handleStatus(a.id, "RECHAZADO")}>Rechazar</button>
</>
)}
{a.status === "RECIBIDO" && (
<button className="btn btn-primary btn-sm" disabled={updating === a.id}
onClick={() => handleStatus(a.id, "VINCULADO")}>Vincular</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+134 -73
View File
@@ -1,86 +1,147 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Calculadora de Envíos SENAE — Moraworld Imports",
description: "Calcula el costo total de tu envío desde EE.UU. a Ecuador incluyendo FODINFA, IVA, arancel y flete. Sin registro.",
};
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function CalculadoraPage() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
const [form, setForm] = useState({ weightLb: "", lengthIn: "", widthIn: "", heightIn: "", declaredValueUsd: "", category: "COURIER" });
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleCalc = async (e: React.FormEvent) => {
e.preventDefault(); setLoading(true); setError(""); setResult(null);
try {
const params: Record<string, string> = { category: form.category };
if (form.weightLb) params.weightLb = form.weightLb;
if (form.lengthIn) params.lengthIn = form.lengthIn;
if (form.widthIn) params.widthIn = form.widthIn;
if (form.heightIn) params.heightIn = form.heightIn;
if (form.declaredValueUsd) params.declaredValueUsd = form.declaredValueUsd;
const data = await api.calculator.calculate(params);
setResult(data);
} catch (err: any) { setError(err.message ?? "Error"); }
finally { setLoading(false); }
};
return (
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
<div style={{ maxWidth: 700, margin: "0 auto" }}>
<div style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginBottom: 8 }}>
Calculadora SENAE
</h1>
<p style={{ color: "#6B7280" }}>
Sin necesidad de registro. Conoce el costo total antes de comprar.
</p>
</div>
{/* Nota: el formulario interactivo se implementa en Fase 1 con client component */}
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB", marginBottom: 24,
}}>
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
Ingresa los datos de tu paquete
</h2>
<p style={{ color: "#6B7280", fontSize: ".88rem", marginBottom: 20 }}>
El formulario interactivo se habilitará en la Fase 1. Por ahora puedes usar el
endpoint de la API directamente:
</p>
<div style={{
background: "#0D1117", borderRadius: 10, padding: "16px 20px",
fontFamily: "monospace", fontSize: ".8rem", color: "#A5B4FC",
lineHeight: 2,
}}>
<div style={{ color: "rgba(255,255,255,.3)", marginBottom: 8 }}>{`// GET /api/calculator`}</div>
<div>
<span style={{ color: "#6EE7B7" }}>GET</span>{" "}
{apiUrl}/api/calculator?<span style={{ color: "#FCD34D" }}>value</span>=150&
<span style={{ color: "#FCD34D" }}>weight</span>=2&
<span style={{ color: "#FCD34D" }}>category</span>=REGIMEN_4X4
</div>
<div style={{ marginTop: 12, color: "rgba(255,255,255,.3)" }}>
{`// Parámetros: value, weight, category, length, width, height, shipments`}
</div>
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/tracking" className="navbar-link">Tracking</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/login" className="btn btn-outline btn-sm">Ingresar</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
</div>
</nav>
{/* Categorías SENAE informativas */}
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB",
}}>
<h2 style={{ fontWeight: 700, marginBottom: 20, fontSize: "1rem" }}>
Categorías SENAE aplicables
</h2>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
{[
{ name: "Régimen 4×4", rate: "0%", color: "#10B981", bg: "#D1FAE5", desc: "Hasta $400 · 4 kg · 4 envíos/año" },
{ name: "Categoría B", rate: "10%", color: "#1D4ED8", bg: "#DBEAFE", desc: "Bienes generales" },
{ name: "Categoría C", rate: "20%", color: "#FF6B00", bg: "#FFEDD5", desc: "Textiles, calzado, hogar" },
{ name: "Categoría D", rate: "015%", color: "#8B5CF6", bg: "#EDE9FE", desc: "Electrónicos · puede requerir INEN" },
].map((c) => (
<div key={c.name} style={{
background: c.bg, borderRadius: 10, padding: 16, textAlign: "center",
}}>
<div style={{ fontWeight: 800, marginBottom: 4 }}>{c.name}</div>
<div style={{ fontSize: "1.8rem", fontWeight: 900, color: c.color, lineHeight: 1, marginBottom: 6 }}>{c.rate}</div>
<div style={{ fontSize: ".75rem", color: "#4B5563" }}>{c.desc}</div>
</div>
))}
</div>
<div style={{ marginTop: 20, padding: 16, background: "#F3F4F6", borderRadius: 8 }}>
<p style={{ fontSize: ".82rem", color: "#6B7280", lineHeight: 1.6 }}>
<strong>Fórmula:</strong> Flete (Peso × $3.50/lb) + Seguro (2%) + FODINFA (0.5%) + Arancel (% categoría) + IVA (15%)
<div style={{ paddingTop: "var(--nav-h)" }}>
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
<div className="container">
<h1 style={{ fontSize: "clamp(1.75rem, 4vw, 2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>Calculadora Aduanera</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 480, margin: "0 auto" }}>
Estima los impuestos SENAE según §15 del reglamento vigente.
</p>
</div>
</div>
<div className="container" style={{ maxWidth: 900, margin: "3rem auto", padding: "0 1.5rem" }}>
<div className="grid-2" style={{ gap: "2rem", alignItems: "start" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Datos del envío</span></div>
<div className="card-body">
{error && <div className="alert alert-error mb-4">{error}</div>}
<form onSubmit={handleCalc} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="label">Régimen aduanero</label>
<select className="select" value={form.category} onChange={set("category")}>
<option value="MENSAJERIA_ACELERADA">Mensajería Acelerada ( $200 · sin impuestos)</option>
<option value="COURIER">Courier ( $400 · impuestos desde $200.01)</option>
<option value="REGIMEN_4X4">Régimen 4×4 ( $2,000)</option>
</select>
</div>
<div className="form-group">
<label className="label">Valor declarado en factura (USD)</label>
<input type="number" className="input" placeholder="150.00" min={0} step="0.01" value={form.declaredValueUsd} onChange={set("declaredValueUsd")} />
</div>
<div className="form-group">
<label className="label">Peso real (lb)</label>
<input type="number" className="input" placeholder="2.5" min={0} step="0.1" value={form.weightLb} onChange={set("weightLb")} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: ".75rem" }}>
{[["Largo", "lengthIn"], ["Ancho", "widthIn"], ["Alto", "heightIn"]].map(([l,k]) => (
<div key={k} className="form-group">
<label className="label">{l} (in)</label>
<input type="number" className="input" placeholder="0" min={0} step="0.1"
value={(form as any)[k]} onChange={set(k)} />
</div>
))}
</div>
<button type="submit" className="btn btn-primary btn-lg" style={{ width: "100%" }} disabled={loading}>
{loading ? "Calculando…" : "Calcular impuestos"}
</button>
</form>
</div>
</div>
{result ? (
<div className="card">
<div className="card-header"><span className="font-semibold">Estimación de costos</span></div>
<div className="card-body">
<div style={{ display: "flex", flexDirection: "column", gap: ".6rem" }}>
{[
["Peso a cobrar", `${result.billableWeightLb?.toFixed(2)} lb`],
["Flete estimado", `$${result.fleteUsd?.toFixed(2)}`],
["Seguro", `$${result.seguroUsd?.toFixed(2)}`],
["FODINFA (0.5%)", `$${result.fodinfaUsd?.toFixed(2)}`],
["Arancel", `$${result.arancelUsd?.toFixed(2)}`],
["IVA (15%)", `$${result.ivaUsd?.toFixed(2)}`],
].map(([label, val]) => (
<div key={label as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-700)" }}>{label}</span>
<span style={{ fontWeight: 600 }}>{val}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", padding: "1rem", background: "var(--primary-light)", borderRadius: "var(--radius)", marginTop: ".5rem" }}>
<span style={{ fontWeight: 700, color: "var(--primary)" }}>Total impuestos</span>
<span style={{ fontWeight: 800, fontSize: "1.25rem", color: "var(--primary)" }}>${result.totalTaxesUsd?.toFixed(2)}</span>
</div>
</div>
{result.exento && <div className="alert alert-success mt-4"><strong>Exento</strong> No aplican impuestos SENAE.</div>}
<div className="alert alert-info mt-4">
<strong>Nota:</strong> Esta es una estimación. Los valores finales dependen de la verificación en aduana. Calculado según §15 del Reglamento SENAE.
</div>
<Link href="/registro" className="btn btn-primary" style={{ width: "100%", marginTop: "1rem" }}>
Crear casillero gratis
</Link>
</div>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{[
{ title: "Mensajería Acelerada", desc: "Hasta $200 USD · No paga impuestos · Ideal para compras pequeñas en Amazon.", badge: "Más popular" },
{ title: "Courier", desc: "Hasta $400 USD · Sin impuestos hasta $200 · Proceso rápido.", badge: "" },
{ title: "Régimen 4×4", desc: "Hasta $2,000 USD · Para compras de mayor valor · Aplican impuestos completos.", badge: "" },
].map(r => (
<div key={r.title} className="card" style={{ padding: "1.25rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".5rem" }}>
<span style={{ fontWeight: 700 }}>{r.title}</span>
{r.badge && <span className="badge badge-green">{r.badge}</span>}
</div>
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{r.desc}</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
</main>
</div>
);
}
@@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export default function CotizacionPage() {
const [form, setForm] = useState({
companyName: "", contactName: "", contactEmail: "", contactPhone: "",
description: "", estimatedMonthlyVolume: "",
});
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState("");
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); setLoading(true); setError("");
try {
await api.b2b.create({
...form,
estimatedMonthlyVolume: form.estimatedMonthlyVolume ? parseFloat(form.estimatedMonthlyVolume) : undefined,
});
setSuccess(true);
} catch (err: any) { setError(err.message ?? "Error al enviar solicitud"); }
finally { setLoading(false); }
};
return (
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/carga-pesada" className="navbar-link">Carga Pesada</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
<div className="container">
<h1 style={{ fontSize: "clamp(1.75rem,4vw,2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>Solicitar Cotización B2B</h1>
<p style={{ color: "rgba(255,255,255,.7)" }}>Un asesor te contactará en menos de 24 horas.</p>
</div>
</div>
<div className="container-sm" style={{ margin: "3rem auto", padding: "0 1.5rem" }}>
{success ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}></div>
<h2 style={{ fontWeight: 700, marginBottom: "1rem" }}>¡Solicitud enviada!</h2>
<p style={{ color: "var(--gray-500)", marginBottom: "2rem" }}>
Hemos recibido tu solicitud. Un asesor de Moraworld se comunicará contigo en menos de 24 horas.
</p>
<div style={{ display: "flex", gap: "1rem", justifyContent: "center" }}>
<Link href="/" className="btn btn-outline">Volver al inicio</Link>
<a href="https://wa.me/17327095300" target="_blank" rel="noopener noreferrer" className="btn btn-success">Contactar por WhatsApp</a>
</div>
</div>
) : (
<div className="card">
<div className="card-header"><span className="font-semibold">Formulario de cotización B2B</span></div>
<div className="card-body">
{error && <div className="alert alert-error mb-4">{error}</div>}
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1.125rem" }}>
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="label">Empresa *</label>
<input className="input" placeholder="Mi Empresa S.A." value={form.companyName} onChange={set("companyName")} required />
</div>
<div className="form-group">
<label className="label">Nombre de contacto *</label>
<input className="input" placeholder="Juan Pérez" value={form.contactName} onChange={set("contactName")} required />
</div>
</div>
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="label">Email *</label>
<input type="email" className="input" placeholder="contacto@empresa.com" value={form.contactEmail} onChange={set("contactEmail")} required />
</div>
<div className="form-group">
<label className="label">Teléfono / WhatsApp</label>
<input className="input" placeholder="+593 99 123 4567" value={form.contactPhone} onChange={set("contactPhone")} />
</div>
</div>
<div className="form-group">
<label className="label">Descripción de la solicitud *</label>
<textarea className="textarea" placeholder="Describe el tipo de mercancía, origen, frecuencia de importación, necesidades especiales…" value={form.description} onChange={set("description")} required />
</div>
<div className="form-group">
<label className="label">Volumen mensual estimado (USD)</label>
<input type="number" className="input" placeholder="5000" min={0} value={form.estimatedMonthlyVolume} onChange={set("estimatedMonthlyVolume")} />
</div>
<button type="submit" className="btn btn-primary btn-lg" style={{ width: "100%" }} disabled={loading}>
{loading ? "Enviando…" : "Enviar solicitud"}
</button>
</form>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
export default function CargaPesadaPage() {
return (
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/como-funciona" className="navbar-link">Cómo funciona</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
<div className="container">
<h1 style={{ fontSize: "clamp(1.75rem,4vw,2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>Carga Pesada y B2B</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 600, margin: "0 auto" }}>
Importaciones de volumen para empresas y emprendedores. Maquinaria, muebles, vehículos y más.
</p>
</div>
</div>
<div className="section">
<div className="container">
<div className="grid-3" style={{ marginBottom: "3rem" }}>
{[
{ icon: "🚢", title: "Carga marítima FCL/LCL", desc: "Contenedores completos o carga consolidada desde puertos de EE.UU. a Ecuador (Guayaquil / Manta)." },
{ icon: "✈️", title: "Flete aéreo express", desc: "Para carga urgente o mercancía de alto valor. Tiempo de tránsito 35 días." },
{ icon: "🏭", title: "Soluciones B2B", desc: "Gestión de importaciones recurrentes para empresas. Precios preferenciales por volumen." },
].map(s => (
<div key={s.title} className="card" style={{ padding: "2rem" }}>
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>{s.icon}</div>
<h3 style={{ fontWeight: 700, marginBottom: ".75rem" }}>{s.title}</h3>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{s.desc}</p>
</div>
))}
</div>
<div style={{ textAlign: "center" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>¿Necesitas importar en volumen?</h2>
<p style={{ color: "var(--gray-500)", marginBottom: "2rem" }}>Completa el formulario y un asesor te contactará en menos de 24 horas.</p>
<Link href="/carga-pesada/cotizacion" className="btn btn-primary btn-xl">Solicitar cotización </Link>
</div>
</div>
</div>
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Link from "next/link";
export default function CasilleroPage() {
const address = [
["Nombre", "TU NOMBRE COMPLETO"],
["Dirección", "150 N Day St"],
["Suite", "EC-XXXXX (tu código)"],
["Ciudad", "City of Orange"],
["Estado", "NJ"],
["ZIP", "07050"],
["País", "United States"],
["Teléfono", "+1 (732) 709-5300"],
];
return (
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/como-funciona" className="navbar-link">Cómo funciona</Link>
<Link href="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
<div className="container">
<h1 style={{ fontSize: "clamp(1.75rem,4vw,2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>Tu Casillero en EE.UU.</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 560, margin: "0 auto" }}>
Dirección real en New Jersey. Compra en Amazon, eBay, Walmart y más.
</p>
</div>
</div>
<div className="section">
<div className="container-sm">
<div className="grid-2" style={{ gap: "2rem", marginBottom: "3rem" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Formato de dirección</span></div>
<div className="card-body">
<div className="address-box">
{address.map(([k, v]) => (
<div key={k as string} className="address-line">
<span className="address-key">{k}</span>
<span className="address-val" style={{ fontSize: ".85rem" }}>{v as string}</span>
</div>
))}
</div>
<div className="alert alert-info mt-4">
<strong>Importante:</strong> Siempre incluye tu Suite en la dirección. Es tu identificador único en nuestra bodega.
</div>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{[
{ icon: "🆓", title: "Gratis siempre", desc: "Tu casillero no tiene costo mensual ni de mantenimiento." },
{ icon: "⚡", title: "Asignación inmediata", desc: "Al registrarte recibes tu código Suite en segundos." },
{ icon: "📸", title: "Fotos al recibir", desc: "Fotografiamos cada paquete antes de enviarlo a Ecuador." },
{ icon: "🔔", title: "Notificaciones", desc: "Te avisamos por email y WhatsApp en cada paso." },
].map(f => (
<div key={f.title} className="card" style={{ padding: "1.25rem", display: "flex", gap: "1rem" }}>
<span style={{ fontSize: "1.5rem", flexShrink: 0 }}>{f.icon}</span>
<div><strong>{f.title}</strong><br /><span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{f.desc}</span></div>
</div>
))}
</div>
</div>
<div style={{ textAlign: "center" }}>
<Link href="/registro" className="btn btn-primary btn-xl">Crear mi casillero gratis </Link>
</div>
</div>
</div>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
const STEPS = [
{ n: 1, title: "Crea tu casillero gratis", desc: "Regístrate en Moraworld y obtén en segundos tu dirección en New Jersey, EE.UU. (Suite EC-XXXXX)." },
{ n: 2, title: "Compra en tiendas de EE.UU.", desc: "Usa tu dirección en Amazon, eBay, Shein, Nike u otras. La Suite es tu número de identificación." },
{ n: 3, title: "Registra una pre-alerta", desc: "Avísanos qué esperas desde el portal para agilizar el proceso cuando llegue a nuestra bodega." },
{ n: 4, title: "Recibimos en bodega NJ", desc: "Inspeccionamos, pesamos y fotografiamos cada paquete. Te notificamos al instante." },
{ n: 5, title: "Gestión aduanera SENAE", desc: "Aplicamos el régimen correcto (Mensajería Acelerada, Courier o 4×4) para minimizar tus costos." },
{ n: 6, title: "Envío a Ecuador", desc: "Consolidamos tu carga y la enviamos por avión o marítimo. Tracking en tiempo real." },
{ n: 7, title: "Entrega en tu puerta", desc: "Coordinamos la entrega a domicilio en cualquier ciudad de Ecuador." },
];
export default function ComoFuncionaPage() {
return (
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/tracking" className="navbar-link">Tracking</Link>
<Link href="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
<div className="container">
<h1 style={{ fontSize: "clamp(1.75rem,4vw,2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>¿Cómo funciona?</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 560, margin: "0 auto" }}>
En 7 simples pasos, compras en EE.UU. y recibes en Ecuador.
</p>
</div>
</div>
<div className="container" style={{ maxWidth: 720, margin: "4rem auto", padding: "0 1.5rem" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{STEPS.map((s, i) => (
<div key={s.n} style={{ display: "flex", gap: "1.5rem" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
<div style={{ width: 48, height: 48, borderRadius: "9999px", background: "var(--primary)", color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: "1.1rem", flexShrink: 0 }}>{s.n}</div>
{i < STEPS.length - 1 && <div style={{ width: 2, flex: 1, background: "var(--gray-200)", marginTop: ".5rem", minHeight: 40 }} />}
</div>
<div style={{ paddingBottom: "1rem" }}>
<h3 style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: ".5rem" }}>{s.title}</h3>
<p style={{ color: "var(--gray-600)", lineHeight: 1.6 }}>{s.desc}</p>
</div>
</div>
))}
</div>
<div style={{ textAlign: "center", marginTop: "4rem" }}>
<Link href="/registro" className="btn btn-primary btn-xl">Empezar gratis </Link>
</div>
</div>
</div>
</div>
);
}
+362 -15
View File
@@ -1,34 +1,381 @@
:root {
--primary: #0057ff;
--primary-dark: #003fc7;
--primary-light: #e8f0fe;
--accent: #ff6b00;
--accent-dark: #cc5500;
--dark: #0d1117;
--gray-900: #111827;
--gray-700: #374151;
--gray-500: #6b7280;
--gray-300: #d1d5db;
--gray-200: #e5e7eb;
--gray-100: #f3f4f6;
--gray-50: #f9fafb;
--white: #ffffff;
--green: #10b981;
--green-light: #d1fae5;
--red: #ef4444;
--red-light: #fee2e2;
--yellow: #f59e0b;
--yellow-light: #fef3c7;
--blue-light: #dbeafe;
--radius: 8px;
--radius-lg: 16px;
--shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06);
--shadow-md: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.06);
--shadow-lg: 0 10px 15px rgba(0,0,0,.1), 0 4px 6px rgba(0,0,0,.05);
--shadow-xl: 0 20px 25px rgba(0,0,0,.1), 0 10px 10px rgba(0,0,0,.04);
--nav-h: 64px;
--sidebar-w: 240px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { font-family: var(--font-inter), system-ui, sans-serif; color: var(--gray-900); background: var(--white); line-height: 1.6; }
a { color: inherit; text-decoration: none; }
img { max-width: 100%; display: block; }
button { cursor: pointer; border: none; background: none; font-family: inherit; }
input, textarea, select { font-family: inherit; }
/* ─── Typography ─────────────────────────────────────── */
.text-xs { font-size: .75rem; line-height: 1rem; }
.text-sm { font-size: .875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.text-4xl { font-size: 2.25rem; line-height: 2.5rem; }
.text-5xl { font-size: 3rem; line-height: 1; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.text-primary { color: var(--primary); }
.text-accent { color: var(--accent); }
.text-muted { color: var(--gray-500); }
.text-white { color: var(--white); }
.text-green { color: var(--green); }
.text-red { color: var(--red); }
.text-yellow { color: var(--yellow); }
/* ─── Layout ─────────────────────────────────────────── */
.container { max-width: 1200px; margin: 0 auto; padding: 0 1.5rem; }
.container-sm { max-width: 720px; margin: 0 auto; padding: 0 1.5rem; }
.container-xs { max-width: 480px; margin: 0 auto; padding: 0 1.5rem; }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1; }
.gap-1 { gap: .25rem; }
.gap-2 { gap: .5rem; }
.gap-3 { gap: .75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-8 { gap: 2rem; }
.grid { display: grid; }
.grid-2 { display: grid; grid-template-columns: repeat(2,1fr); gap: 1.5rem; }
.grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 1.5rem; }
.grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 1.5rem; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.min-h-screen { min-height: 100vh; }
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.inset-0 { inset: 0; }
.z-10 { z-index: 10; }
.z-50 { z-index: 50; }
.z-100 { z-index: 100; }
.overflow-hidden { overflow: hidden; }
.overflow-auto { overflow: auto; }
/* ─── Spacing ────────────────────────────────────────── */
.p-2 { padding: .5rem; }
.p-3 { padding: .75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.p-8 { padding: 2rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-2 { padding-top: .5rem; padding-bottom: .5rem; }
.py-3 { padding-top: .75rem; padding-bottom: .75rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
.py-12 { padding-top: 3rem; padding-bottom: 3rem; }
.py-16 { padding-top: 4rem; padding-bottom: 4rem; }
.py-20 { padding-top: 5rem; padding-bottom: 5rem; }
.mt-1 { margin-top: .25rem; }
.mt-2 { margin-top: .5rem; }
.mt-4 { margin-top: 1rem; }
.mt-6 { margin-top: 1.5rem; }
.mt-8 { margin-top: 2rem; }
.mb-2 { margin-bottom: .5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mb-8 { margin-bottom: 2rem; }
.mx-auto { margin-left: auto; margin-right: auto; }
/* ─── Colors / Backgrounds ───────────────────────────── */
.bg-white { background: var(--white); }
.bg-gray-50 { background: var(--gray-50); }
.bg-gray-100 { background: var(--gray-100); }
.bg-dark { background: var(--dark); }
.bg-primary { background: var(--primary); }
.bg-primary-light { background: var(--primary-light); }
.bg-accent { background: var(--accent); }
.bg-green { background: var(--green); }
.bg-green-light { background: var(--green-light); }
.bg-red-light { background: var(--red-light); }
.bg-yellow-light { background: var(--yellow-light); }
.bg-blue-light { background: var(--blue-light); }
/* ─── Borders / Radius ───────────────────────────────── */
.rounded { border-radius: var(--radius); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-full { border-radius: 9999px; }
.border { border: 1px solid var(--gray-200); }
.border-2 { border: 2px solid var(--gray-200); }
.border-primary { border-color: var(--primary); }
/* ─── Shadows ────────────────────────────────────────── */
.shadow { box-shadow: var(--shadow); }
.shadow-md { box-shadow: var(--shadow-md); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.shadow-xl { box-shadow: var(--shadow-xl); }
/* ─── Buttons ────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: .5rem; padding: .625rem 1.25rem; border-radius: var(--radius);
font-size: .9rem; font-weight: 600; line-height: 1; transition: all .2s;
cursor: pointer; white-space: nowrap; border: 2px solid transparent;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: var(--white); }
.btn-primary:hover:not(:disabled) { background: var(--primary-dark); }
.btn-accent { background: var(--accent); color: var(--white); }
.btn-accent:hover:not(:disabled) { background: var(--accent-dark); }
.btn-outline { background: transparent; border-color: var(--primary); color: var(--primary); }
.btn-outline:hover:not(:disabled) { background: var(--primary); color: var(--white); }
.btn-ghost { background: transparent; color: var(--gray-700); }
.btn-ghost:hover:not(:disabled) { background: var(--gray-100); }
.btn-danger { background: var(--red); color: var(--white); }
.btn-danger:hover:not(:disabled) { background: #dc2626; }
.btn-success { background: var(--green); color: var(--white); }
.btn-sm { padding: .375rem .875rem; font-size: .8125rem; }
.btn-lg { padding: .875rem 2rem; font-size: 1rem; }
.btn-xl { padding: 1rem 2.5rem; font-size: 1.1rem; }
/* ─── Forms ──────────────────────────────────────────── */
.form-group { display: flex; flex-direction: column; gap: .375rem; }
.label { font-size: .875rem; font-weight: 500; color: var(--gray-700); }
.input {
width: 100%; padding: .625rem .875rem; border-radius: var(--radius);
border: 1.5px solid var(--gray-300); font-size: .9375rem; transition: border-color .2s;
background: var(--white); color: var(--gray-900);
}
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(0,87,255,.1); }
.input.error { border-color: var(--red); }
.select {
width: 100%; padding: .625rem .875rem; border-radius: var(--radius);
border: 1.5px solid var(--gray-300); font-size: .9375rem; background: var(--white);
color: var(--gray-900); cursor: pointer; transition: border-color .2s;
}
.select:focus { outline: none; border-color: var(--primary); }
.textarea {
width: 100%; padding: .625rem .875rem; border-radius: var(--radius);
border: 1.5px solid var(--gray-300); font-size: .9375rem; resize: vertical;
min-height: 100px; transition: border-color .2s;
}
.textarea:focus { outline: none; border-color: var(--primary); }
.form-error { font-size: .8125rem; color: var(--red); margin-top: .25rem; }
.form-hint { font-size: .8125rem; color: var(--gray-500); }
/* ─── Cards ──────────────────────────────────────────── */
.card {
background: var(--white); border-radius: var(--radius-lg);
border: 1px solid var(--gray-200); box-shadow: var(--shadow);
}
.card-header { padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--gray-200); }
.card-body { padding: 1.5rem; }
.card-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--gray-200); background: var(--gray-50); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
/* ─── Alerts / Badges ────────────────────────────────── */
.alert { padding: .875rem 1rem; border-radius: var(--radius); font-size: .9rem; }
.alert-success { background: var(--green-light); color: #065f46; border: 1px solid #6ee7b7; }
.alert-error { background: var(--red-light); color: #991b1b; border: 1px solid #fca5a5; }
.alert-warning { background: var(--yellow-light); color: #92400e; border: 1px solid #fcd34d; }
.alert-info { background: var(--blue-light); color: #1e40af; border: 1px solid #93c5fd; }
.badge {
display: inline-flex; align-items: center; gap: .25rem;
padding: .2rem .6rem; border-radius: 9999px;
font-size: .75rem; font-weight: 600; white-space: nowrap;
}
.badge-blue { background: var(--blue-light); color: #1e40af; }
.badge-green { background: var(--green-light); color: #065f46; }
.badge-yellow { background: var(--yellow-light); color: #92400e; }
.badge-red { background: var(--red-light); color: #991b1b; }
.badge-gray { background: var(--gray-100); color: var(--gray-700); }
.badge-orange { background: #ffedd5; color: #9a3412; }
/* ─── Tables ─────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border-radius: var(--radius-lg); border: 1px solid var(--gray-200); }
table { width: 100%; border-collapse: collapse; font-size: .875rem; }
thead { background: var(--gray-50); }
th { padding: .75rem 1rem; text-align: left; font-weight: 600; color: var(--gray-700); white-space: nowrap; border-bottom: 1px solid var(--gray-200); }
td { padding: .75rem 1rem; border-bottom: 1px solid var(--gray-100); vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: var(--gray-50); }
/* ─── Navbar ─────────────────────────────────────────── */
.navbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 50;
height: var(--nav-h); background: rgba(255,255,255,.95);
backdrop-filter: blur(8px); border-bottom: 1px solid var(--gray-200);
display: flex; align-items: center;
}
.navbar-inner { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.navbar-logo { font-size: 1.25rem; font-weight: 800; color: var(--primary); }
.navbar-logo span { color: var(--accent); }
.navbar-links { display: flex; align-items: center; gap: 1.5rem; }
.navbar-link { font-size: .9rem; color: var(--gray-700); font-weight: 500; transition: color .2s; }
.navbar-link:hover { color: var(--primary); }
/* ─── Dashboard Sidebar ──────────────────────────────── */
.dash-layout { display: flex; min-height: 100vh; }
.dash-sidebar {
width: var(--sidebar-w); background: var(--dark); color: var(--white);
position: fixed; top: 0; left: 0; bottom: 0; z-index: 40;
display: flex; flex-direction: column; overflow-y: auto;
}
.dash-logo { padding: 1.5rem; font-size: 1.1rem; font-weight: 800; color: var(--white); border-bottom: 1px solid rgba(255,255,255,.1); }
.dash-logo span { color: var(--accent); }
.dash-nav { padding: 1rem 0; flex: 1; }
.dash-nav-item {
display: flex; align-items: center; gap: .75rem;
padding: .625rem 1.5rem; font-size: .875rem; color: rgba(255,255,255,.7);
transition: all .2s; cursor: pointer; border-left: 3px solid transparent;
}
.dash-nav-item:hover { background: rgba(255,255,255,.07); color: var(--white); }
.dash-nav-item.active { background: rgba(0,87,255,.2); color: var(--white); border-left-color: var(--primary); }
.dash-nav-section { padding: .5rem 1.5rem .25rem; font-size: .7rem; font-weight: 700; text-transform: uppercase; color: rgba(255,255,255,.35); letter-spacing: .05em; margin-top: .5rem; }
.dash-user { padding: 1rem 1.5rem; border-top: 1px solid rgba(255,255,255,.1); }
.dash-user-name { font-size: .875rem; font-weight: 600; }
.dash-user-role { font-size: .75rem; color: rgba(255,255,255,.5); }
.dash-main { margin-left: var(--sidebar-w); flex: 1; display: flex; flex-direction: column; }
.dash-topbar { height: var(--nav-h); background: var(--white); border-bottom: 1px solid var(--gray-200); display: flex; align-items: center; justify-content: space-between; padding: 0 2rem; position: sticky; top: 0; z-index: 30; }
.dash-content { padding: 2rem; flex: 1; background: var(--gray-50); }
.dash-page-title { font-size: 1.5rem; font-weight: 700; color: var(--gray-900); }
.dash-page-subtitle { font-size: .9rem; color: var(--gray-500); margin-top: .25rem; }
/* ─── Stats Cards ────────────────────────────────────── */
.stat-card {
background: var(--white); border-radius: var(--radius-lg);
padding: 1.5rem; border: 1px solid var(--gray-200); box-shadow: var(--shadow);
}
.stat-value { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: .875rem; color: var(--gray-500); margin-top: .25rem; }
.stat-change { font-size: .8rem; margin-top: .5rem; }
/* ─── WhatsApp Float ─────────────────────────────────── */
.whatsapp-float {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 999;
width: 56px; height: 56px; border-radius: 9999px;
background: #25d366; display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 16px rgba(37,211,102,.4); transition: transform .2s, box-shadow .2s;
}
.whatsapp-float:hover { transform: scale(1.1); box-shadow: 0 6px 24px rgba(37,211,102,.5); }
.whatsapp-float svg { width: 28px; height: 28px; fill: white; }
/* ─── Hero ───────────────────────────────────────────── */
.hero {
background: linear-gradient(135deg, var(--dark) 0%, #0f2050 100%);
color: var(--white); padding: 8rem 0 5rem;
}
.hero-badge {
display: inline-flex; align-items: center; gap: .5rem;
background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.2);
padding: .375rem 1rem; border-radius: 9999px; font-size: .8rem; margin-bottom: 1.5rem;
}
.hero-title { font-size: clamp(2rem, 5vw, 3.5rem); font-weight: 800; line-height: 1.15; margin-bottom: 1.25rem; }
.hero-title .accent { color: var(--accent); }
.hero-address {
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.15);
border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; font-family: monospace; font-size: .9rem;
}
html {
scroll-behavior: smooth;
/* ─── Section ────────────────────────────────────────── */
.section { padding: 5rem 0; }
.section-gray { padding: 5rem 0; background: var(--gray-50); }
.section-dark { padding: 5rem 0; background: var(--dark); color: var(--white); }
.section-title { font-size: clamp(1.5rem, 3vw, 2.25rem); font-weight: 700; margin-bottom: .75rem; }
.section-sub { font-size: 1.05rem; color: var(--gray-500); max-width: 560px; margin: 0 auto 3rem; }
/* ─── Steps ──────────────────────────────────────────── */
.step-number {
width: 48px; height: 48px; border-radius: 9999px;
background: var(--primary); color: var(--white);
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; font-weight: 800; flex-shrink: 0;
}
.step-connector { width: 2px; height: 40px; background: var(--gray-200); margin: .5rem auto; }
/* ─── Tracking Timeline ──────────────────────────────── */
.timeline { position: relative; padding-left: 2rem; }
.timeline::before { content: ''; position: absolute; left: .75rem; top: 0; bottom: 0; width: 2px; background: var(--gray-200); }
.timeline-item { position: relative; padding-bottom: 1.25rem; }
.timeline-dot {
position: absolute; left: -1.625rem; top: .25rem;
width: 14px; height: 14px; border-radius: 9999px;
border: 2px solid var(--white); box-shadow: 0 0 0 2px var(--gray-300);
}
.timeline-dot.active { background: var(--green); box-shadow: 0 0 0 3px var(--green-light); }
.timeline-dot.current { background: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); }
.timeline-dot.pending { background: var(--gray-300); }
/* ─── Loader / Spinner ───────────────────────────────── */
.spinner {
width: 40px; height: 40px; border-radius: 9999px;
border: 3px solid var(--gray-200); border-top-color: var(--primary);
animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-overlay {
position: fixed; inset: 0; background: rgba(255,255,255,.85); z-index: 9999;
display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 1rem;
}
body {
font-family: var(--font-inter), system-ui, sans-serif;
color: var(--gray-900);
background: var(--white);
line-height: 1.6;
}
/* ─── Misc ───────────────────────────────────────────── */
.divider { height: 1px; background: var(--gray-200); margin: 1.5rem 0; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hidden { display: none; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); }
.pointer { cursor: pointer; }
.no-select { user-select: none; }
.code-box { background: var(--gray-100); border-radius: var(--radius); padding: .25rem .5rem; font-family: monospace; font-size: .875rem; color: var(--primary); }
.address-box { background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: var(--radius-lg); padding: 1.25rem 1.5rem; font-size: .9rem; }
.address-box .address-line { display: flex; justify-content: space-between; align-items: center; padding: .35rem 0; border-bottom: 1px solid var(--gray-100); }
.address-box .address-line:last-child { border-bottom: none; }
.address-box .address-key { font-weight: 500; color: var(--gray-700); min-width: 110px; }
.address-box .address-val { font-family: monospace; color: var(--gray-900); }
a {
color: inherit;
text-decoration: none;
/* ─── Responsive ─────────────────────────────────────── */
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
.dash-sidebar { transform: translateX(-100%); transition: transform .3s; }
.dash-sidebar.open { transform: translateX(0); }
.dash-main { margin-left: 0; }
.hide-mobile { display: none; }
}
@media (max-width: 1024px) {
.grid-4 { grid-template-columns: repeat(2,1fr); }
.grid-3 { grid-template-columns: repeat(2,1fr); }
}
+18 -12
View File
@@ -2,25 +2,31 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "Moraworld Imports — Casillero en EE.UU. para Ecuador",
description:
"Compra en Amazon, eBay y más. Tu casillero en New Jersey con envío y gestión aduanera a Ecuador.",
description: "Compra en Amazon, eBay y más. Tu casillero en New Jersey con envío y gestión aduanera a Ecuador.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body className={inter.variable}>{children}</body>
<body className={inter.variable}>
{children}
{/* WhatsApp flotante */}
<a
href="https://wa.me/17327095300?text=Hola%20Moraworld%2C%20necesito%20información"
target="_blank"
rel="noopener noreferrer"
className="whatsapp-float"
aria-label="Contactar por WhatsApp"
>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
</a>
</body>
</html>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api, setToken, setRefresh, setUser } from "@/lib/api";
export default function LoginPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", totpCode: "" });
const [requiresMfa, setRequiresMfa] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const data = await api.auth.login(form);
if (data.requiresMfa) {
setRequiresMfa(true);
setLoading(false);
return;
}
setToken(data.accessToken);
setRefresh(data.refreshToken);
setUser(data.user);
// Redirigir según rol
const role = data.user.role;
if (role === "ADMIN_EMPRESA" || role === "SUPER_ADMIN") router.push("/admin");
else if (role === "OPERADOR_BODEGA" || role === "AGENTE_ADUANERO") router.push("/bodega");
else router.push("/portal");
} catch (err: any) {
setError(err.message ?? "Error al iniciar sesión");
} finally {
setLoading(false);
}
};
return (
<div style={{ minHeight: "100vh", background: "linear-gradient(135deg, #0d1117 0%, #0f2050 100%)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1.5rem" }}>
<div style={{ width: "100%", maxWidth: 420 }}>
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" style={{ fontSize: "1.5rem", fontWeight: 800, color: "white" }}>
Moraworld<span style={{ color: "var(--accent)" }}>.</span>
</Link>
<p style={{ color: "rgba(255,255,255,.6)", marginTop: ".5rem", fontSize: ".9rem" }}>
Inicia sesión en tu cuenta
</p>
</div>
<div className="card" style={{ padding: "2rem" }}>
{error && <div className="alert alert-error mb-6">{error}</div>}
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{!requiresMfa ? (
<>
<div className="form-group">
<label className="label">Correo electrónico</label>
<input
type="email" className="input" placeholder="tu@email.com"
value={form.email} onChange={e => setForm({...form, email: e.target.value})}
required autoFocus
/>
</div>
<div className="form-group">
<label className="label">Contraseña</label>
<input
type="password" className="input" placeholder="••••••••"
value={form.password} onChange={e => setForm({...form, password: e.target.value})}
required
/>
</div>
</>
) : (
<div className="form-group">
<label className="label">Código de autenticación (MFA)</label>
<input
type="text" className="input" placeholder="123456" maxLength={6}
value={form.totpCode} onChange={e => setForm({...form, totpCode: e.target.value})}
required autoFocus pattern="\d{6}"
/>
<p className="form-hint">Ingresa el código de tu app de autenticación.</p>
</div>
)}
<button type="submit" className="btn btn-primary" style={{ width: "100%", padding: ".875rem" }} disabled={loading}>
{loading ? "Ingresando..." : requiresMfa ? "Verificar código" : "Iniciar sesión"}
</button>
</form>
{!requiresMfa && (
<div className="text-center mt-6">
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>
¿No tienes cuenta?{" "}
<Link href="/registro" style={{ color: "var(--primary)", fontWeight: 600 }}>
Regístrate gratis
</Link>
</p>
</div>
)}
{requiresMfa && (
<button className="btn btn-ghost" style={{ width: "100%", marginTop: "1rem", fontSize: ".875rem" }}
onClick={() => { setRequiresMfa(false); setForm({...form, totpCode: ""}); }}>
Volver al login
</button>
)}
</div>
<div className="text-center mt-6">
<Link href="/" style={{ fontSize: ".8rem", color: "rgba(255,255,255,.4)" }}>
Volver al sitio
</Link>
</div>
</div>
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
export default function CalculadoraPortalPage() {
const [form, setForm] = useState({ weightLb: "", lengthIn: "", widthIn: "", heightIn: "", declaredValueUsd: "", category: "COURIER" });
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleCalculate = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true); setError(""); setResult(null);
try {
const params: Record<string, string> = { category: form.category };
if (form.weightLb) params.weightLb = form.weightLb;
if (form.lengthIn) params.lengthIn = form.lengthIn;
if (form.widthIn) params.widthIn = form.widthIn;
if (form.heightIn) params.heightIn = form.heightIn;
if (form.declaredValueUsd) params.declaredValueUsd = form.declaredValueUsd;
const data = await api.calculator.calculate(params);
setResult(data);
} catch (err: any) { setError(err.message ?? "Error en el cálculo"); }
finally { setLoading(false); }
};
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Calculadora Aduanera</h1>
<p className="dash-page-subtitle">Estima los costos SENAE para tu importación (§15 Reglamento).</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Datos del paquete</span></div>
<div className="card-body">
{error && <div className="alert alert-error mb-4">{error}</div>}
<form onSubmit={handleCalculate} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="label">Régimen aduanero</label>
<select className="select" value={form.category} onChange={set("category")}>
<option value="COURIER">Courier (hasta $400)</option>
<option value="MENSAJERIA_ACELERADA">Mensajería Acelerada (hasta $200)</option>
<option value="REGIMEN_4X4">Régimen 4×4 (hasta $2000)</option>
</select>
</div>
<div className="form-group">
<label className="label">Valor declarado (USD)</label>
<input type="number" className="input" placeholder="150.00" min={0} step="0.01" value={form.declaredValueUsd} onChange={set("declaredValueUsd")} />
</div>
<div className="form-group">
<label className="label">Peso real (lb)</label>
<input type="number" className="input" placeholder="2.5" min={0} step="0.1" value={form.weightLb} onChange={set("weightLb")} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: ".75rem" }}>
{[["Largo (in)", "lengthIn"], ["Ancho (in)", "widthIn"], ["Alto (in)", "heightIn"]].map(([label, key]) => (
<div key={key} className="form-group">
<label className="label">{label}</label>
<input type="number" className="input" placeholder="0" min={0} step="0.1"
value={(form as any)[key]} onChange={set(key)} />
</div>
))}
</div>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "Calculando…" : "Calcular costos"}
</button>
</form>
</div>
</div>
{result && (
<div className="card">
<div className="card-header"><span className="font-semibold">Resultado estimado</span></div>
<div className="card-body">
<div style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
{[
["Peso a cobrar (lb)", result.billableWeightLb?.toFixed(2)],
["Flete estimado (USD)", `$${result.fleteUsd?.toFixed(2)}`],
["Seguro (USD)", `$${result.seguroUsd?.toFixed(2)}`],
["FODINFA", `$${result.fodinfaUsd?.toFixed(2)}`],
["Arancel", `$${result.arancelUsd?.toFixed(2)}`],
["IVA", `$${result.ivaUsd?.toFixed(2)}`],
].map(([label, val]) => val !== undefined && (
<div key={label as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-700)" }}>{label}</span>
<span style={{ fontWeight: 600 }}>{val}</span>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", padding: ".75rem 0", background: "var(--primary-light)", borderRadius: "var(--radius)", paddingLeft: "1rem", paddingRight: "1rem", marginTop: ".5rem" }}>
<span style={{ fontWeight: 700, color: "var(--primary)" }}>Total impuestos</span>
<span style={{ fontWeight: 800, fontSize: "1.2rem", color: "var(--primary)" }}>${result.totalTaxesUsd?.toFixed(2)}</span>
</div>
{result.exento && (
<div className="alert alert-success">
<strong>Exento de impuestos</strong> Este paquete califica bajo el monto mínimo SENAE.
</div>
)}
</div>
<p style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: "1rem" }}>
* Estimación basada en §15 SENAE. Los valores finales pueden variar.
</p>
</div>
</div>
)}
{!result && (
<div className="card" style={{ background: "var(--gray-50)" }}>
<div className="card-body">
<div style={{ fontWeight: 600, marginBottom: "1rem" }}>Información aduanera</div>
{[
{ regime: "Mensajería Acelerada", limit: "Hasta $200 USD · Exento de impuestos" },
{ regime: "Courier", limit: "Hasta $400 USD · Aplican impuestos desde $200.01" },
{ regime: "Régimen 4×4", limit: "Hasta $2,000 USD · Con impuestos completos" },
].map(r => (
<div key={r.regime} style={{ padding: ".75rem", background: "white", borderRadius: "var(--radius)", marginBottom: ".75rem", border: "1px solid var(--gray-200)" }}>
<div style={{ fontWeight: 600, fontSize: ".9rem" }}>{r.regime}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".2rem" }}>{r.limit}</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth, getRefresh } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/portal", icon: "◈", label: "Dashboard" },
{ href: "/portal/mi-casillero", icon: "📦", label: "Mi Casillero" },
{ href: "/portal/mis-paquetes", icon: "🚚", label: "Mis Paquetes" },
{ href: "/portal/pre-alerta", icon: "🔔", label: "Pre-Alerta" },
{ href: "/portal/calculadora", icon: "🧮", label: "Calculadora" },
{ href: "/portal/perfil", icon: "👤", label: "Mi Perfil" },
];
export default function PortalLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
const [unread, setUnread] = useState(0);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
setUser(u);
}, [router]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
clearAuth();
router.push("/login");
};
if (!user) return <div className="loading-overlay"><div className="spinner" /></div>;
return (
<div className="dash-layout">
{/* Sidebar */}
<aside className="dash-sidebar">
<div className="dash-logo">Mora<span>world</span></div>
<nav className="dash-nav">
{NAV.map(item => (
<Link key={item.href} href={item.href}
className={`dash-nav-item ${pathname === item.href ? "active" : ""}`}>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
))}
</nav>
<div className="dash-user">
<div className="dash-user-name">{user.firstName} {user.lastName}</div>
<div className="dash-user-role">Cliente</div>
<button className="btn btn-ghost btn-sm" style={{ marginTop: ".5rem", color: "rgba(255,255,255,.5)", fontSize: ".8rem" }} onClick={handleLogout}>
Cerrar sesión
</button>
</div>
</aside>
{/* Main */}
<div className="dash-main">
<header className="dash-topbar">
<span style={{ fontSize: "1rem", fontWeight: 600, color: "var(--gray-900)" }}>Portal del Cliente</span>
<div className="flex items-center gap-4">
<Link href="/portal/notificaciones" style={{ position: "relative", fontSize: "1.2rem" }}>
🔔 {unread > 0 && <span style={{ position: "absolute", top: -6, right: -8, background: "var(--red)", color: "white", borderRadius: "9999px", fontSize: ".65rem", fontWeight: 700, padding: "0 4px", minWidth: 16, textAlign: "center" }}>{unread}</span>}
</Link>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{user.email}</span>
</div>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function MiCasilleroPage() {
const [suite, setSuite] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState<string | null>(null);
useEffect(() => {
api.auth.me().then(me => setSuite(me.suite)).catch(() => {}).finally(() => setLoading(false));
}, []);
const copy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(key);
setTimeout(() => setCopied(null), 2000);
});
};
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
if (!suite) return (
<div className="card" style={{ padding: "2rem", textAlign: "center" }}>
<p style={{ color: "var(--gray-500)" }}>No tienes un casillero asignado. Contacta a soporte.</p>
</div>
);
const address = [
{ key: "Nombre", val: "Tu nombre completo" },
{ key: "Dirección", val: "150 N Day St" },
{ key: "Suite", val: suite.code },
{ key: "Ciudad", val: "City of Orange" },
{ key: "Estado", val: "NJ" },
{ key: "ZIP", val: "07050" },
{ key: "País", val: "United States" },
{ key: "Teléfono", val: "+1 (732) 709-5300" },
];
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Mi Casillero</h1>
<p className="dash-page-subtitle">Usa esta dirección al hacer compras en tiendas de EE.UU.</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
<div className="card">
<div className="card-header">
<span className="font-semibold">Tu dirección de envío en EE.UU.</span>
</div>
<div className="card-body">
<div className="address-box">
{address.map(a => (
<div key={a.key} className="address-line">
<span className="address-key">{a.key}</span>
<span className="address-val">{a.val}</span>
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: ".75rem", padding: ".2rem .5rem" }}
onClick={() => copy(a.val, a.key)}
>
{copied === a.key ? "✓" : "Copiar"}
</button>
</div>
))}
</div>
<div className="alert alert-info mt-4">
<strong>Dirección completa:</strong><br />
<span style={{ fontFamily: "monospace", fontSize: ".9rem" }}>
{suite.address ?? `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`}
</span>
</div>
<button
className="btn btn-outline mt-4"
style={{ width: "100%" }}
onClick={() => copy(`150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, United States`, "full")}
>
{copied === "full" ? "✓ Copiado" : "Copiar dirección completa"}
</button>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
<div className="card" style={{ borderLeft: "4px solid var(--primary)" }}>
<div className="card-body">
<div style={{ fontWeight: 700, fontSize: "1.1rem", color: "var(--primary)" }}>Código de casillero</div>
<div style={{ fontSize: "2rem", fontWeight: 800, letterSpacing: 2, marginTop: ".5rem" }}>{suite.code}</div>
</div>
</div>
<div className="card">
<div className="card-body">
<div style={{ fontWeight: 600, marginBottom: "1rem" }}>Cómo usar tu casillero</div>
{[
"Compra en Amazon, eBay, Shein u otras tiendas.",
"Al pagar, usa la dirección de arriba como dirección de envío.",
"Crea una pre-alerta para que sepamos qué viene.",
"Cuando llegue a NJ lo procesamos y enviamos a Ecuador.",
].map((s, i) => (
<div key={i} style={{ display: "flex", gap: ".75rem", marginBottom: ".75rem", fontSize: ".9rem" }}>
<span style={{ width: 24, height: 24, borderRadius: "9999px", background: "var(--primary)", color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 700, fontSize: ".8rem", flexShrink: 0 }}>{i + 1}</span>
<span>{s}</span>
</div>
))}
</div>
</div>
<div className="card" style={{ background: "var(--gray-50)" }}>
<div className="card-body">
<div style={{ fontWeight: 600, marginBottom: ".5rem" }}>¿Tienes dudas?</div>
<p style={{ fontSize: ".875rem", color: "var(--gray-500)", marginBottom: "1rem" }}>Nuestro equipo está disponible por WhatsApp para ayudarte.</p>
<a href="https://wa.me/17327095300" target="_blank" rel="noopener noreferrer"
className="btn btn-success" style={{ width: "100%" }}>
Contactar por WhatsApp
</a>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
const STATUS_LABEL: Record<string, string> = {
RECIBIDO_EN_NJ: "Recibido en NJ", EN_PROCESO: "En proceso",
EN_CAMINO_A_ECUADOR: "En camino a Ecuador", EN_ADUANA: "En aduana",
EN_BODEGA_EC: "En bodega EC", LISTO_PARA_RETIRO: "Listo para retiro",
ENTREGADO: "Entregado", RETENIDO_ADUANA: "Retenido", DEVUELTO: "Devuelto",
PERDIDO: "Perdido", CANCELADO: "Cancelado",
};
const STATUS_BADGE: Record<string, string> = {
RECIBIDO_EN_NJ: "badge-blue", EN_PROCESO: "badge-yellow",
EN_CAMINO_A_ECUADOR: "badge-orange", EN_ADUANA: "badge-yellow",
EN_BODEGA_EC: "badge-blue", LISTO_PARA_RETIRO: "badge-green",
ENTREGADO: "badge-green", RETENIDO_ADUANA: "badge-red",
DEVUELTO: "badge-red", PERDIDO: "badge-red", CANCELADO: "badge-gray",
};
export default function MisPaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState<any | null>(null);
useEffect(() => {
api.packages.list().then(setPackages).catch(() => {}).finally(() => setLoading(false));
}, []);
const filtered = packages.filter(p =>
!filter ||
p.trackingId?.toLowerCase().includes(filter.toLowerCase()) ||
p.trackingNumber?.toLowerCase().includes(filter.toLowerCase()) ||
p.description?.toLowerCase().includes(filter.toLowerCase())
);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
return (
<div>
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
<div>
<h1 className="dash-page-title">Mis Paquetes</h1>
<p className="dash-page-subtitle">Historial completo de tus envíos.</p>
</div>
<input
className="input" style={{ maxWidth: 280 }}
placeholder="Buscar por código o descripción..."
value={filter} onChange={e => setFilter(e.target.value)}
/>
</div>
{filtered.length === 0 ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>📦</div>
<p style={{ color: "var(--gray-500)" }}>{filter ? "No hay paquetes que coincidan." : "Aún no tienes paquetes registrados."}</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{filtered.map(p => (
<div key={p.id} className="card pointer" onClick={() => setSelected(p === selected ? null : p)}>
<div className="card-body" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "1rem" }}>
<div>
<div style={{ fontWeight: 700, fontSize: "1rem", color: "var(--primary)" }}>{p.trackingId}</div>
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", marginTop: ".2rem" }}>
{p.description ?? "Sin descripción"} · {p.trackingNumber ?? ""}
</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
{new Date(p.createdAt).toLocaleDateString("es-EC")}
</div>
</div>
<div style={{ textAlign: "right" }}>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>{STATUS_LABEL[p.status] ?? p.status}</span>
{p.weightLb && <div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".4rem" }}>{p.weightLb} lb</div>}
</div>
</div>
{/* Historial expandido */}
{selected?.id === p.id && (
<div className="card-footer">
<div style={{ fontWeight: 600, marginBottom: ".75rem" }}>Historial de estados</div>
{p.statusHistory?.length ? (
<div className="timeline">
{p.statusHistory.map((h: any, i: number) => (
<div key={h.id} className="timeline-item">
<div className={`timeline-dot ${i === 0 ? "current" : "active"}`} />
<div>
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>{STATUS_LABEL[h.status] ?? h.status}</div>
{h.notes && <div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{h.notes}</div>}
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>{new Date(h.createdAt).toLocaleString("es-EC")}</div>
</div>
</div>
))}
</div>
) : <p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>Sin historial.</p>}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api, getUser } from "@/lib/api";
const STATUS_LABEL: Record<string, string> = {
RECIBIDO_EN_NJ: "Recibido en NJ",
EN_PROCESO: "En proceso",
EN_CAMINO_A_ECUADOR: "En camino a Ecuador",
EN_ADUANA: "En aduana",
EN_BODEGA_EC: "En bodega EC",
LISTO_PARA_RETIRO: "Listo para retiro",
ENTREGADO: "Entregado",
RETENIDO_ADUANA: "Retenido en aduana",
DEVUELTO: "Devuelto",
PERDIDO: "Perdido",
CANCELADO: "Cancelado",
};
const STATUS_BADGE: Record<string, string> = {
RECIBIDO_EN_NJ: "badge-blue", EN_PROCESO: "badge-yellow",
EN_CAMINO_A_ECUADOR: "badge-orange", EN_ADUANA: "badge-yellow",
EN_BODEGA_EC: "badge-blue", LISTO_PARA_RETIRO: "badge-green",
ENTREGADO: "badge-green", RETENIDO_ADUANA: "badge-red",
DEVUELTO: "badge-red", PERDIDO: "badge-red", CANCELADO: "badge-gray",
};
export default function PortalDashboard() {
const user = getUser();
const [packages, setPackages] = useState<any[]>([]);
const [notifications, setNotifications] = useState<any[]>([]);
const [suite, setSuite] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.packages.list(),
api.auth.me(),
]).then(([pkgs, me]) => {
setPackages(pkgs.slice(0, 5));
setNotifications([]);
setSuite(me.suite);
}).catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
const active = packages.filter(p => !["ENTREGADO","CANCELADO","DEVUELTO"].includes(p.status)).length;
const delivered = packages.filter(p => p.status === "ENTREGADO").length;
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Bienvenido, {user?.firstName} 👋</h1>
<p className="dash-page-subtitle">Gestiona tus envíos y tu casillero en NJ.</p>
</div>
{/* Stats */}
<div className="grid-4" style={{ marginBottom: "2rem" }}>
{[
{ label: "Paquetes activos", value: active, color: "var(--primary)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "Pre-alertas", value: "—", color: "var(--yellow)" },
{ label: "Mi casillero", value: suite?.code ?? "—", color: "var(--accent)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Últimos paquetes */}
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Paquetes recientes</span>
<Link href="/portal/mis-paquetes" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
</div>
<div className="card-body" style={{ padding: 0 }}>
{packages.length === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)", textAlign: "center" }}>Aún no tienes paquetes.</p>
) : (
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<tbody>
{packages.map(p => (
<tr key={p.id}>
<td><Link href={`/portal/mis-paquetes?id=${p.id}`} style={{ color: "var(--primary)", fontWeight: 600 }}>{p.trackingId}</Link></td>
<td className="text-sm text-muted truncate" style={{ maxWidth: 120 }}>{p.description ?? "—"}</td>
<td><span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>{STATUS_LABEL[p.status] ?? p.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Notificaciones */}
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Notificaciones</span>
<span className="badge badge-red">{notifications.length}</span>
</div>
<div className="card-body" style={{ padding: 0 }}>
{notifications.length === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)", textAlign: "center" }}>Sin notificaciones nuevas.</p>
) : (
<div style={{ display: "flex", flexDirection: "column" }}>
{notifications.map(n => (
<div key={n.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600, fontSize: ".9rem" }}>{n.title}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".2rem" }}>{n.body}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
import { api, getUser, setUser } from "@/lib/api";
export default function PerfilPage() {
const [user, setUserState] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [mfaSetup, setMfaSetup] = useState<any>(null);
const [totpCode, setTotpCode] = useState("");
const [mfaMsg, setMfaMsg] = useState("");
const [mfaError, setMfaError] = useState("");
useEffect(() => {
api.auth.me().then(me => { setUserState(me); setUser(me); }).catch(() => {}).finally(() => setLoading(false));
}, []);
const handleSetupMfa = async () => {
try {
const data = await api.auth.setupMfa();
setMfaSetup(data);
} catch (err: any) { setMfaError(err.message); }
};
const handleVerifyMfa = async (e: React.FormEvent) => {
e.preventDefault(); setMfaError(""); setMfaMsg("");
try {
await api.auth.verifyMfa(totpCode);
setMfaMsg("MFA activado correctamente.");
setMfaSetup(null); setTotpCode("");
const me = await api.auth.me();
setUserState(me); setUser(me);
} catch (err: any) { setMfaError(err.message ?? "Código inválido"); }
};
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Mi Perfil</h1>
<p className="dash-page-subtitle">Información de tu cuenta.</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
<div className="card">
<div className="card-header"><span className="font-semibold">Datos personales</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
{[
["Nombre", `${user.firstName} ${user.lastName}`],
["Email", user.email],
["Teléfono", user.phone ?? "—"],
["Rol", user.role],
["Casillero", user.suite?.code ?? "—"],
["Miembro desde", new Date(user.createdAt).toLocaleDateString("es-EC")],
["Último acceso", user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString("es-EC") : "—"],
].map(([k, v]) => (
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
<span style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>{k}</span>
<span style={{ fontWeight: 500, fontSize: ".9rem" }}>{v as string}</span>
</div>
))}
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Seguridad</span></div>
<div className="card-body">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
<div>
<div style={{ fontWeight: 600 }}>Autenticación en dos pasos (MFA)</div>
<div style={{ fontSize: ".85rem", color: "var(--gray-500)", marginTop: ".2rem" }}>
{user.mfaEnabled ? "Activada — tu cuenta tiene protección extra." : "No activada — te recomendamos habilitarla."}
</div>
</div>
<span className={`badge ${user.mfaEnabled ? "badge-green" : "badge-gray"}`}>
{user.mfaEnabled ? "Activada" : "Desactivada"}
</span>
</div>
{mfaMsg && <div className="alert alert-success mb-4">{mfaMsg}</div>}
{mfaError && <div className="alert alert-error mb-4">{mfaError}</div>}
{!user.mfaEnabled && !mfaSetup && (
<button className="btn btn-outline" onClick={handleSetupMfa}>Activar MFA</button>
)}
{mfaSetup && (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="alert alert-info">
<strong>1.</strong> Escanea este QR con Google Authenticator, Authy, u otra app TOTP.<br />
<strong>2.</strong> Ingresa el código de 6 dígitos para confirmar.
</div>
<div style={{ background: "var(--gray-100)", borderRadius: "var(--radius)", padding: "1rem", wordBreak: "break-all", fontSize: ".8rem", fontFamily: "monospace" }}>
{mfaSetup.otpAuthUrl}
</div>
<p style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>Secret: <code>{mfaSetup.secret}</code></p>
<form onSubmit={handleVerifyMfa} style={{ display: "flex", gap: ".75rem" }}>
<input className="input" placeholder="Código TOTP (6 dígitos)" value={totpCode}
onChange={e => setTotpCode(e.target.value)} maxLength={6} pattern="\d{6}" required />
<button type="submit" className="btn btn-primary">Verificar</button>
</form>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
export default function PreAlertaPage() {
const [alerts, setAlerts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState("");
const [error, setError] = useState("");
const [form, setForm] = useState({ store: "", orderNumber: "", description: "", estimatedValueUsd: "", estimatedWeightLb: "", trackingNumber: "" });
const load = () => { api.preAlerts.list().then(setAlerts).catch(() => {}).finally(() => setLoading(false)); };
useEffect(() => { load(); }, []);
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true); setError(""); setSuccess("");
try {
await api.preAlerts.create({
...form,
estimatedValueUsd: form.estimatedValueUsd ? parseFloat(form.estimatedValueUsd) : undefined,
estimatedWeightLb: form.estimatedWeightLb ? parseFloat(form.estimatedWeightLb) : undefined,
});
setSuccess("Pre-alerta registrada exitosamente.");
setForm({ store: "", orderNumber: "", description: "", estimatedValueUsd: "", estimatedWeightLb: "", trackingNumber: "" });
load();
} catch (err: any) { setError(err.message ?? "Error"); }
finally { setSubmitting(false); }
};
const handleDelete = async (id: string) => {
if (!confirm("¿Eliminar esta pre-alerta?")) return;
await api.preAlerts.delete(id).catch(() => {});
load();
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow", RECIBIDO: "badge-blue", VINCULADO: "badge-green", RECHAZADO: "badge-red",
};
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Pre-Alertas</h1>
<p className="dash-page-subtitle">Avísanos qué paquetes esperas para procesarlos más rápido.</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
{/* Formulario */}
<div className="card">
<div className="card-header"><span className="font-semibold">Nueva Pre-Alerta</span></div>
<div className="card-body">
{success && <div className="alert alert-success mb-4">{success}</div>}
{error && <div className="alert alert-error mb-4">{error}</div>}
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div className="form-group">
<label className="label">Tienda *</label>
<input className="input" placeholder="Amazon, eBay, Shein…" value={form.store} onChange={set("store")} required />
</div>
<div className="form-group">
<label className="label">Número de orden *</label>
<input className="input" placeholder="123-4567890-1234567" value={form.orderNumber} onChange={set("orderNumber")} required />
</div>
<div className="form-group">
<label className="label">Descripción</label>
<textarea className="textarea" placeholder="Descripción del producto" value={form.description} onChange={set("description")} style={{ minHeight: 70 }} />
</div>
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="label">Valor est. (USD)</label>
<input type="number" className="input" placeholder="50.00" min={0} step="0.01" value={form.estimatedValueUsd} onChange={set("estimatedValueUsd")} />
</div>
<div className="form-group">
<label className="label">Peso est. (lb)</label>
<input type="number" className="input" placeholder="2.5" min={0} step="0.1" value={form.estimatedWeightLb} onChange={set("estimatedWeightLb")} />
</div>
</div>
<div className="form-group">
<label className="label">Tracking del proveedor</label>
<input className="input" placeholder="1Z999AA10123456784" value={form.trackingNumber} onChange={set("trackingNumber")} />
</div>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Enviando…" : "Registrar Pre-Alerta"}
</button>
</form>
</div>
</div>
{/* Lista */}
<div className="card">
<div className="card-header"><span className="font-semibold">Mis Pre-Alertas</span></div>
<div style={{ padding: loading ? "2rem" : 0, textAlign: "center" }}>
{loading ? <div className="spinner mx-auto" /> :
alerts.length === 0 ? (
<p style={{ padding: "2rem", color: "var(--gray-500)" }}>No tienes pre-alertas.</p>
) : (
alerts.map(a => (
<div key={a.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div style={{ fontWeight: 600 }}>{a.store}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>Orden: {a.orderNumber}</div>
{a.description && <div style={{ fontSize: ".8rem", color: "var(--gray-400)" }}>{a.description}</div>}
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".4rem" }}>
<span className={`badge ${STATUS_BADGE[a.status] ?? "badge-gray"}`}>{a.status}</span>
{a.status === "PENDIENTE" && (
<button className="btn btn-ghost btn-sm text-red" style={{ fontSize: ".75rem", color: "var(--red)" }} onClick={() => handleDelete(a.id)}>Eliminar</button>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import Link from "next/link";
export default function QuienesSomosPage() {
return (
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/como-funciona" className="navbar-link">Cómo funciona</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
<div className="container">
<h1 style={{ fontSize: "clamp(1.75rem,4vw,2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>Quiénes somos</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 560, margin: "0 auto" }}>Conectamos Ecuador con el mundo.</p>
</div>
</div>
<div className="section">
<div className="container-sm">
<div className="grid-2" style={{ gap: "3rem", alignItems: "center", marginBottom: "4rem" }}>
<div>
<h2 style={{ fontSize: "1.75rem", fontWeight: 700, marginBottom: "1rem" }}>Nuestra misión</h2>
<p style={{ color: "var(--gray-600)", lineHeight: 1.8, marginBottom: "1rem" }}>
Moraworld Imports nació con el objetivo de democratizar el acceso a productos del mercado estadounidense para los ecuatorianos.
Ofrecemos un casillero en New Jersey, logística, gestión aduanera SENAE y entrega a domicilio.
</p>
<p style={{ color: "var(--gray-600)", lineHeight: 1.8 }}>
Operamos bajo las normativas COMEX y SENAE vigentes, garantizando transparencia en cada importación.
</p>
</div>
<div style={{ background: "var(--gray-50)", borderRadius: "var(--radius-lg)", padding: "2rem" }}>
{[["Casillero en NJ", "City of Orange, New Jersey — cerca del puerto de Newark."],
["Entidad legal USA", "Moraworld LLC — registrada en New Jersey."],
["Entidad legal EC", "Moraworld S.A.S. — registrada en Ecuador."],
["Operación", "Equipo bilingüe en NJ y Ecuador."],
].map(([k, v]) => (
<div key={k as string} style={{ display: "flex", gap: "1rem", marginBottom: "1.25rem" }}>
<div style={{ width: 8, height: 8, borderRadius: "9999px", background: "var(--primary)", marginTop: 8, flexShrink: 0 }} />
<div><strong>{k}</strong><br /><span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{v as string}</span></div>
</div>
))}
</div>
</div>
<div style={{ textAlign: "center", padding: "3rem", background: "var(--primary)", borderRadius: "var(--radius-lg)", color: "white" }}>
<h2 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>¿Tienes una empresa? Soluciones B2B</h2>
<p style={{ opacity: .85, marginBottom: "1.5rem" }}>Gestión de importaciones de volumen, representación comercial y logística empresarial.</p>
<Link href="/carga-pesada/cotizacion" className="btn btn-accent btn-lg">Solicitar cotización B2B</Link>
</div>
</div>
</div>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { api, setToken, setRefresh, setUser } from "@/lib/api";
export default function RegistroPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", confirmPassword: "", firstName: "", lastName: "", phone: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (form.password !== form.confirmPassword) { setError("Las contraseñas no coinciden."); return; }
if (form.password.length < 8) { setError("La contraseña debe tener al menos 8 caracteres."); return; }
setLoading(true);
try {
const { confirmPassword, ...body } = form;
const data = await api.auth.register(body);
setToken(data.accessToken);
setRefresh(data.refreshToken);
setUser(data.user);
router.push("/portal/mi-casillero");
} catch (err: any) {
setError(err.message ?? "Error al registrarse");
} finally {
setLoading(false);
}
};
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
return (
<div style={{ minHeight: "100vh", background: "linear-gradient(135deg, #0d1117 0%, #0f2050 100%)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1.5rem" }}>
<div style={{ width: "100%", maxWidth: 480 }}>
<div className="text-center mb-8">
<Link href="/" style={{ fontSize: "1.5rem", fontWeight: 800, color: "white" }}>
Moraworld<span style={{ color: "var(--accent)" }}>.</span>
</Link>
<p style={{ color: "rgba(255,255,255,.6)", marginTop: ".5rem", fontSize: ".9rem" }}>
Crea tu casillero gratis en 1 minuto
</p>
</div>
<div className="card" style={{ padding: "2rem" }}>
{error && <div className="alert alert-error mb-6">{error}</div>}
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1.125rem" }}>
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="label">Nombre</label>
<input type="text" className="input" placeholder="Juan" value={form.firstName} onChange={set("firstName")} required minLength={2} />
</div>
<div className="form-group">
<label className="label">Apellido</label>
<input type="text" className="input" placeholder="Pérez" value={form.lastName} onChange={set("lastName")} required minLength={2} />
</div>
</div>
<div className="form-group">
<label className="label">Correo electrónico</label>
<input type="email" className="input" placeholder="juan@email.com" value={form.email} onChange={set("email")} required />
</div>
<div className="form-group">
<label className="label">Teléfono (opcional)</label>
<input type="tel" className="input" placeholder="+593 99 123 4567" value={form.phone} onChange={set("phone")} />
</div>
<div className="form-group">
<label className="label">Contraseña</label>
<input type="password" className="input" placeholder="Mínimo 8 caracteres" value={form.password} onChange={set("password")} required minLength={8} />
</div>
<div className="form-group">
<label className="label">Confirmar contraseña</label>
<input type="password" className="input" placeholder="Repite tu contraseña" value={form.confirmPassword} onChange={set("confirmPassword")} required />
</div>
<button type="submit" className="btn btn-primary" style={{ width: "100%", padding: ".875rem", marginTop: ".25rem" }} disabled={loading}>
{loading ? "Creando cuenta..." : "Crear mi casillero gratis"}
</button>
</form>
<p style={{ fontSize: ".75rem", color: "var(--gray-500)", textAlign: "center", marginTop: "1rem", lineHeight: 1.5 }}>
Al registrarte aceptas nuestros{" "}
<Link href="/terminos" style={{ color: "var(--primary)" }}>Términos de Servicio</Link>{" "}
y{" "}
<Link href="/privacidad" style={{ color: "var(--primary)" }}>Política de Privacidad</Link>.
</p>
<div className="text-center mt-4">
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>
¿Ya tienes cuenta?{" "}
<Link href="/login" style={{ color: "var(--primary)", fontWeight: 600 }}>Inicia sesión</Link>
</p>
</div>
</div>
<div className="text-center mt-6">
<Link href="/" style={{ fontSize: ".8rem", color: "rgba(255,255,255,.4)" }}> Volver al sitio</Link>
</div>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
const TARIFAS = [
{ regime: "Mensajería Acelerada", limit: "Hasta $200 USD", taxes: "Sin impuestos", flete: "$8$15/lb", ideal: "Compras pequeñas · fast fashion · accesorios", badge: "Más popular" },
{ regime: "Courier", limit: "Hasta $400 USD", taxes: "Sin impuestos hasta $200", flete: "$8$15/lb", ideal: "Electrónica · ropa · zapatos", badge: "" },
{ regime: "Régimen 4×4", limit: "Hasta $2,000 USD", taxes: "FODINFA + Arancel + IVA", flete: "Según peso/volumen", ideal: "Equipos, repuestos, herramientas", badge: "" },
{ regime: "Carga pesada (FCL)", limit: "Sin límite", taxes: "Trámite formal", flete: "Cotización por m³", ideal: "Maquinaria · muebles · vehículos", badge: "" },
];
export default function TarifasPublicaPage() {
return (
<div style={{ minHeight: "100vh" }}>
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/como-funciona" className="navbar-link">Cómo funciona</Link>
<Link href="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem", textAlign: "center" }}>
<div className="container">
<h1 style={{ fontSize: "clamp(1.75rem,4vw,2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>Tarifas y Costos</h1>
<p style={{ color: "rgba(255,255,255,.7)", maxWidth: 560, margin: "0 auto" }}>Precios transparentes, sin sorpresas en aduana.</p>
</div>
</div>
<div className="section-gray">
<div className="container">
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "1.5rem" }}>
{TARIFAS.map(t => (
<div key={t.regime} className="card" style={{ padding: "1.5rem", position: "relative", borderTop: t.badge ? "4px solid var(--primary)" : "4px solid transparent" }}>
{t.badge && <span className="badge badge-blue" style={{ position: "absolute", top: "1rem", right: "1rem" }}>{t.badge}</span>}
<h3 style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: ".75rem" }}>{t.regime}</h3>
<div style={{ display: "flex", flexDirection: "column", gap: ".5rem", fontSize: ".9rem" }}>
<div><span style={{ color: "var(--gray-500)" }}>Límite: </span><strong>{t.limit}</strong></div>
<div><span style={{ color: "var(--gray-500)" }}>Impuestos: </span><strong>{t.taxes}</strong></div>
<div><span style={{ color: "var(--gray-500)" }}>Flete: </span><strong>{t.flete}</strong></div>
</div>
<div style={{ marginTop: "1rem", padding: ".75rem", background: "var(--gray-50)", borderRadius: "var(--radius)", fontSize: ".8rem", color: "var(--gray-500)" }}>
Ideal para: {t.ideal}
</div>
</div>
))}
</div>
<div style={{ textAlign: "center", marginTop: "3rem" }}>
<p style={{ color: "var(--gray-500)", marginBottom: "1rem" }}>¿Quieres saber exactamente cuánto pagarás?</p>
<Link href="/calculadora" className="btn btn-primary btn-lg">Usar la calculadora </Link>
</div>
<div className="alert alert-info" style={{ marginTop: "2rem" }}>
<strong>Impuestos SENAE:</strong> FODINFA 0.5% · Arancel 010% según partida arancelaria · IVA 15%.
Calculados sobre CIF (costo + seguro + flete). Los montos del régimen 4×4 se basan en facturas originales. §15 Reglamento COMEX.
</div>
</div>
</div>
</div>
</div>
);
}
+125 -72
View File
@@ -1,87 +1,140 @@
import type { Metadata } from "next";
"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
export const metadata: Metadata = {
title: "Rastrear Paquete — Moraworld Imports",
description: "Busca tu paquete con el Tracking ID (formato EC-YYYYMMDD-XXXXXX). Sin necesidad de iniciar sesión.",
const STATUS_LABEL: Record<string, string> = {
RECIBIDO_EN_NJ: "Recibido en bodega NJ", EN_PROCESO: "En proceso",
EN_CAMINO_A_ECUADOR: "En camino a Ecuador", EN_ADUANA: "En aduana Ecuador",
EN_BODEGA_EC: "En bodega Ecuador", LISTO_PARA_RETIRO: "Listo para retiro",
ENTREGADO: "Entregado", RETENIDO_ADUANA: "Retenido en aduana",
DEVUELTO: "Devuelto al origen", PERDIDO: "Reportado como perdido", CANCELADO: "Cancelado",
};
export default function TrackingPage() {
const [query, setQuery] = useState("");
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault(); if (!query.trim()) return;
setLoading(true); setError(""); setResult(null);
try {
const data = await api.tracking.search(query.trim());
setResult(data);
} catch (err: any) { setError("No se encontró ningún paquete con ese código."); }
finally { setLoading(false); }
};
const allStatuses = ["RECIBIDO_EN_NJ","EN_PROCESO","EN_CAMINO_A_ECUADOR","EN_ADUANA","EN_BODEGA_EC","LISTO_PARA_RETIRO","ENTREGADO"];
const currentIdx = result ? allStatuses.indexOf(result.status) : -1;
return (
<main style={{ minHeight: "100vh", background: "#F3F4F6", padding: "40px 20px" }}>
<div style={{ maxWidth: 600, margin: "0 auto" }}>
<div style={{ marginBottom: 32 }}>
<Link href="/" style={{ color: "#6B7280", fontSize: ".88rem" }}> Inicio</Link>
<h1 style={{ fontSize: "1.8rem", fontWeight: 800, marginTop: 8, marginBottom: 8 }}>
Rastrear paquete
</h1>
<p style={{ color: "#6B7280" }}>
Ingresa tu Tracking ID para ver el estado de tu paquete. No necesitas iniciar sesión.
</p>
</div>
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB", marginBottom: 24,
}}>
{/* Formulario — cliente component en Fase 1 */}
<label style={{ display: "block", fontWeight: 600, marginBottom: 8, fontSize: ".9rem" }}>
Tracking ID
</label>
<div style={{
background: "#F3F4F6", borderRadius: 8, padding: "12px 16px",
fontFamily: "monospace", color: "#9CA3AF", fontSize: ".9rem",
border: "1px solid #E5E7EB", marginBottom: 8,
}}>
EC-20260506-082341
<div style={{ minHeight: "100vh" }}>
{/* Nav */}
<nav className="navbar">
<div className="container navbar-inner">
<Link href="/" className="navbar-logo">Moraworld<span>.</span></Link>
<div className="navbar-links">
<Link href="/como-funciona" className="navbar-link">Cómo funciona</Link>
<Link href="/tarifas" className="navbar-link">Tarifas</Link>
<Link href="/calculadora" className="navbar-link">Calculadora</Link>
<Link href="/login" className="btn btn-outline btn-sm">Ingresar</Link>
<Link href="/registro" className="btn btn-primary btn-sm">Crear casillero</Link>
</div>
<p style={{ color: "#9CA3AF", fontSize: ".78rem", marginBottom: 20 }}>
Formato: <strong style={{ fontFamily: "monospace" }}>EC-YYYYMMDD-XXXXXX</strong>
</p>
<div style={{
background: "#EEF3FF", borderRadius: 8, padding: "12px 16px",
fontSize: ".84rem", color: "#1D4ED8",
}}>
El formulario de búsqueda interactiva se habilita en Fase 1.
Por ahora puedes usar la API: <code style={{ fontFamily: "monospace" }}>GET /api/tracking/:id</code>
</div>
</nav>
<div style={{ paddingTop: "var(--nav-h)" }}>
{/* Hero */}
<div style={{ background: "linear-gradient(135deg, var(--dark) 0%, #0f2050 100%)", color: "white", padding: "4rem 0 3rem" }}>
<div className="container" style={{ maxWidth: 640, margin: "0 auto", textAlign: "center" }}>
<h1 style={{ fontSize: "clamp(1.75rem, 4vw, 2.5rem)", fontWeight: 800, marginBottom: "1rem" }}>
Rastrea tu paquete
</h1>
<p style={{ color: "rgba(255,255,255,.7)", marginBottom: "2rem" }}>
Ingresa tu código de tracking Moraworld (EC-XXXXXXXX-XXXXXX)
</p>
<form onSubmit={handleSearch} style={{ display: "flex", gap: ".75rem" }}>
<input
className="input" style={{ flex: 1, fontSize: "1rem" }}
placeholder="EC-20260506-000001"
value={query} onChange={e => setQuery(e.target.value)}
/>
<button type="submit" className="btn btn-accent btn-lg" disabled={loading}>
{loading ? "…" : "Buscar"}
</button>
</form>
</div>
</div>
<div style={{
background: "#fff", borderRadius: 16, padding: 32,
border: "1px solid #E5E7EB",
}}>
<h2 style={{ fontWeight: 700, marginBottom: 16, fontSize: "1rem" }}>
11 estados del ciclo de vida
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{[
{ num: "01", name: "REGISTRADO", color: "#6B7280", desc: "Cliente registró la compra" },
{ num: "02", name: "EN_TRANSITO_BODEGA", color: "#F59E0B", desc: "Despachado hacia NJ" },
{ num: "03", name: "RECIBIDO_BODEGA", color: "#3B82F6", desc: "Bodega NJ confirmó recepción" },
{ num: "04", name: "EN_VERIFICACION", color: "#8B5CF6", desc: "Pesando y fotografiando" },
{ num: "05", name: "VERIFICADO", color: "#10B981", desc: "Peso confirmado" },
{ num: "06", name: "DECLARACION_ADUANERA", color: "#0057FF", desc: "DSI aprobada por SENAE" },
{ num: "07", name: "EN_TRANSITO_ECUADOR", color: "#F97316", desc: "En vuelo a Ecuador" },
{ num: "08", name: "EN_ADUANA_ECUADOR", color: "#EF4444", desc: "Inspección en aduana" },
{ num: "09", name: "LISTO_ENTREGA", color: "#84CC16", desc: "Listo para entrega" },
{ num: "10", name: "ENTREGADO", color: "#10B981", desc: "Entregado al cliente ✓" },
{ num: "11", name: "INCIDENCIA", color: "#EF4444", desc: "Problema reportado ⚠" },
].map((s) => (
<div key={s.num} style={{
display: "flex", gap: 10, alignItems: "center",
padding: "8px 12px", background: "#F9FAFB",
borderRadius: 6, border: "1px solid #E5E7EB",
}}>
<div style={{ width: 8, height: 8, borderRadius: "50%", background: s.color, flexShrink: 0 }} />
<span style={{ fontFamily: "monospace", fontSize: ".65rem", color: "#9CA3AF", minWidth: 18 }}>{s.num}</span>
<span style={{ fontFamily: "monospace", fontSize: ".75rem", fontWeight: 700, flex: 1 }}>{s.name}</span>
<span style={{ fontSize: ".72rem", color: "#6B7280" }}>{s.desc}</span>
{/* Resultado */}
<div className="container" style={{ maxWidth: 720, margin: "3rem auto", padding: "0 1.5rem" }}>
{error && <div className="alert alert-error">{error}</div>}
{result && (
<div className="card">
<div className="card-header flex justify-between items-center flex-wrap gap-2">
<div>
<div style={{ fontWeight: 800, fontSize: "1.25rem", color: "var(--primary)" }}>{result.trackingId}</div>
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", marginTop: ".25rem" }}>
{result.description ?? "Paquete"} · {result.trackingNumber ?? ""}
</div>
</div>
<span className="badge badge-green" style={{ fontSize: ".9rem", padding: ".4rem .9rem" }}>
{STATUS_LABEL[result.status] ?? result.status}
</span>
</div>
))}
</div>
<div className="card-body">
{/* Barra de progreso */}
<div style={{ marginBottom: "2rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: ".5rem" }}>
{allStatuses.map((s, i) => (
<div key={s} style={{ flex: 1, textAlign: "center" }}>
<div style={{ width: 20, height: 20, borderRadius: "9999px", margin: "0 auto .35rem", background: i <= currentIdx ? "var(--green)" : "var(--gray-200)", border: i === currentIdx ? "3px solid var(--primary)" : "2px solid transparent", transition: "background .3s" }} />
<div style={{ fontSize: ".6rem", color: i <= currentIdx ? "var(--green)" : "var(--gray-400)", fontWeight: i === currentIdx ? 700 : 400, lineHeight: 1.2 }}>
{s.replace(/_/g,"\n")}
</div>
</div>
))}
</div>
<div style={{ height: 4, background: "var(--gray-200)", borderRadius: 9999, margin: "0 10px" }}>
<div style={{ height: "100%", background: "var(--green)", borderRadius: 9999, width: `${currentIdx >= 0 ? (currentIdx / (allStatuses.length - 1)) * 100 : 0}%`, transition: "width .5s" }} />
</div>
</div>
{/* Historial */}
{result.statusHistory?.length > 0 && (
<>
<div style={{ fontWeight: 600, marginBottom: "1rem" }}>Historial de movimientos</div>
<div className="timeline">
{result.statusHistory.map((h: any, i: number) => (
<div key={h.id ?? i} className="timeline-item">
<div className={`timeline-dot ${i === 0 ? "current" : "active"}`} />
<div>
<div style={{ fontWeight: 600 }}>{STATUS_LABEL[h.status] ?? h.status}</div>
{h.notes && <div style={{ fontSize: ".85rem", color: "var(--gray-500)" }}>{h.notes}</div>}
{h.location && <div style={{ fontSize: ".85rem", color: "var(--gray-500)" }}>📍 {h.location}</div>}
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>{new Date(h.createdAt).toLocaleString("es-EC")}</div>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
)}
{!result && !error && (
<div style={{ textAlign: "center", padding: "2rem", color: "var(--gray-500)" }}>
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>📦</div>
<p>Ingresa tu código de tracking para ver el estado de tu paquete.</p>
</div>
)}
</div>
</div>
</main>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api";
// ─── Auth token storage ─────────────────────────────────────────────────────
export const getToken = (): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem("mw_access");
};
export const setToken = (t: string) => localStorage.setItem("mw_access", t);
export const getRefresh = (): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem("mw_refresh");
};
export const setRefresh = (t: string) => localStorage.setItem("mw_refresh", t);
export const clearAuth = () => {
localStorage.removeItem("mw_access");
localStorage.removeItem("mw_refresh");
localStorage.removeItem("mw_user");
};
export const getUser = (): any | null => {
if (typeof window === "undefined") return null;
try { return JSON.parse(localStorage.getItem("mw_user") ?? "null"); } catch { return null; }
};
export const setUser = (u: any) => localStorage.setItem("mw_user", JSON.stringify(u));
// ─── Fetch wrapper ──────────────────────────────────────────────────────────
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(opts.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, { ...opts, headers });
// Token expirado — intentar refresh
if (res.status === 401 && getRefresh()) {
const refreshed = await tryRefresh();
if (refreshed) {
headers["Authorization"] = `Bearer ${getToken()}`;
const retry = await fetch(`${API_BASE}${path}`, { ...opts, headers });
if (!retry.ok) throw await extractError(retry);
return retry.json();
} else {
clearAuth();
window.location.href = "/login";
throw new Error("Session expired");
}
}
if (!res.ok) throw await extractError(res);
if (res.status === 204) return undefined as T;
return res.json();
}
async function extractError(res: Response): Promise<Error> {
try {
const body = await res.json();
return new Error(body.message ?? "Error del servidor");
} catch {
return new Error(`HTTP ${res.status}`);
}
}
async function tryRefresh(): Promise<boolean> {
const token = getRefresh();
if (!token) return false;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: token }),
});
if (!res.ok) return false;
const data = await res.json();
setToken(data.accessToken);
setRefresh(data.refreshToken);
return true;
} catch { return false; }
}
// ─── Auth ───────────────────────────────────────────────────────────────────
export const api = {
auth: {
register: (body: any) => request<any>("/auth/register", { method: "POST", body: JSON.stringify(body) }),
login: (body: any) => request<any>("/auth/login", { method: "POST", body: JSON.stringify(body) }),
me: () => request<any>("/auth/me"),
logout: (refreshToken: string) => request<any>("/auth/logout", { method: "POST", body: JSON.stringify({ refreshToken }) }),
setupMfa: () => request<any>("/auth/mfa/setup", { method: "POST" }),
verifyMfa:(totpCode: string) => request<any>("/auth/mfa/verify", { method: "POST", body: JSON.stringify({ totpCode }) }),
},
packages: {
list: (params?: Record<string,string>) => request<any[]>("/packages" + (params ? "?" + new URLSearchParams(params) : "")),
get: (id: string) => request<any>(`/packages/${id}`),
create: (body: any) => request<any>("/packages", { method: "POST", body: JSON.stringify(body) }),
updateStatus: (id: string, body: any) => request<any>(`/packages/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
},
preAlerts: {
list: () => request<any[]>("/pre-alerts"),
create: (body: any) => request<any>("/pre-alerts", { method: "POST", body: JSON.stringify(body) }),
delete: (id: string) => request<any>(`/pre-alerts/${id}`, { method: "DELETE" }),
updateStatus: (id: string, body: any) => request<any>(`/pre-alerts/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
},
users: {
list: (search?: string) => request<any[]>("/users" + (search ? `?search=${search}` : "")),
get: (id: string) => request<any>(`/users/${id}`),
updateRole: (id: string, role: string) => request<any>(`/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setActive: (id: string, isActive: boolean) => request<any>(`/users/${id}/active`, { method: "PATCH", body: JSON.stringify({ isActive }) }),
},
tracking: {
search: (id: string) => request<any>(`/tracking/${id}`),
},
calculator: {
calculate: (params: Record<string,string>) => request<any>("/calculator?" + new URLSearchParams(params)),
},
b2b: {
create: (body: any) => request<any>("/b2b", { method: "POST", body: JSON.stringify(body) }),
list: () => request<any[]>("/b2b"),
updateStatus: (id: string, body: any) => request<any>(`/b2b/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
},
};
+383 -18
View File
@@ -22,16 +22,43 @@ importers:
version: link:../../packages/database
'@nestjs/common':
specifier: ^11.1.0
version: 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/config':
specifier: ^4.0.2
version: 4.0.4(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
version: 4.0.4(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/core':
specifier: ^11.1.0
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt':
specifier: ^11.0.2
version: 11.0.2(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/passport':
specifier: ^11.0.5
version: 11.0.5(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/platform-express':
specifier: ^11.1.0
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
version: 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)
bcrypt:
specifier: ^6.0.0
version: 6.0.0
class-transformer:
specifier: ^0.5.1
version: 0.5.1
class-validator:
specifier: ^0.15.1
version: 0.15.1
otplib:
specifier: ^13.4.1
version: 13.4.1
passport:
specifier: ^0.7.0
version: 0.7.0
passport-jwt:
specifier: ^4.0.1
version: 4.0.1
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -47,7 +74,10 @@ importers:
version: 11.1.0(chokidar@4.0.3)(typescript@5.9.3)
'@nestjs/testing':
specifier: ^11.1.0
version: 11.1.24(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)
version: 11.1.24(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/express':
specifier: ^5.0.1
version: 5.0.6
@@ -57,6 +87,9 @@ importers:
'@types/node':
specifier: ^22.15.21
version: 22.19.19
'@types/passport-jwt':
specifier: ^4.0.1
version: 4.0.1
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.19.19)
@@ -69,6 +102,9 @@ importers:
apps/web:
dependencies:
js-cookie:
specifier: ^3.0.8
version: 3.0.8
next:
specifier: ^15.3.2
version: 15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -79,6 +115,9 @@ importers:
specifier: ^19.1.0
version: 19.2.6(react@19.2.6)
devDependencies:
'@types/js-cookie':
specifier: ^3.0.6
version: 3.0.6
'@types/node':
specifier: ^22.15.21
version: 22.19.19
@@ -897,6 +936,17 @@ packages:
'@nestjs/websockets':
optional: true
'@nestjs/jwt@11.0.2':
resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/passport@11.0.5':
resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
passport: ^0.5.0 || ^0.6.0 || ^0.7.0
'@nestjs/platform-express@11.1.21':
resolution: {integrity: sha512-lA3ViycOnz4Df3EstIKpuAVFhqxQixTnjAVk0M+LRyNBlGM6VSCaNJaAIrb9Pcry39T4hTHpNVbRqGLSvhL8gA==}
peerDependencies:
@@ -925,6 +975,13 @@ packages:
'@nestjs/platform-express':
optional: true
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@next/env@15.5.18':
resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==}
@@ -976,11 +1033,33 @@ packages:
cpu: [x64]
os: [win32]
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@nuxt/opencollective@0.4.1':
resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==}
engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'}
hasBin: true
'@otplib/core@13.4.1':
resolution: {integrity: sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==}
'@otplib/hotp@13.4.1':
resolution: {integrity: sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==}
'@otplib/plugin-base32-scure@13.4.1':
resolution: {integrity: sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==}
'@otplib/plugin-crypto-noble@13.4.1':
resolution: {integrity: sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==}
'@otplib/totp@13.4.1':
resolution: {integrity: sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==}
'@otplib/uri@13.4.1':
resolution: {integrity: sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==}
'@prisma/client@6.19.3':
resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==}
engines: {node: '>=18.18'}
@@ -1011,6 +1090,9 @@ packages:
'@prisma/get-platform@6.19.3':
resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==}
'@scure/base@2.2.0':
resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==}
'@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
@@ -1075,6 +1157,9 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -1114,12 +1199,30 @@ packages:
'@types/jest@29.5.14':
resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==}
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@22.19.19':
resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
'@types/passport-jwt@4.0.1':
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
'@types/passport-strategy@0.2.38':
resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==}
'@types/passport@1.0.17':
resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
@@ -1143,6 +1246,9 @@ packages:
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -1330,6 +1436,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -1360,6 +1470,9 @@ packages:
bser@2.1.1:
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -1437,6 +1550,12 @@ packages:
cjs-module-lexer@1.4.3:
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
class-transformer@0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
class-validator@0.15.1:
resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==}
cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
@@ -1617,6 +1736,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -2102,6 +2224,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-cookie@3.0.8:
resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2138,6 +2263,16 @@ packages:
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
@@ -2146,6 +2281,9 @@ packages:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
libphonenumber-js@1.13.4:
resolution: {integrity: sha512-/lhWr7vq8foWN9Apksnd9v8/cfwzW6g6qKOCo25XBGkNaVCHucXO57hLy4CWHGvytvLz6Nt3J5Gs8p3jlCGFXA==}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -2161,9 +2299,30 @@ packages:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
@@ -2302,12 +2461,20 @@ packages:
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-addon-api@8.8.0:
resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==}
engines: {node: ^18 || ^20 || >= 21}
node-emoji@1.11.0:
resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==}
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
@@ -2353,6 +2520,9 @@ packages:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
otplib@13.4.1:
resolution: {integrity: sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@@ -2381,6 +2551,17 @@ packages:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
passport-jwt@4.0.1:
resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==}
passport-strategy@1.0.0:
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
engines: {node: '>= 0.4.0'}
passport@0.7.0:
resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==}
engines: {node: '>= 0.4.0'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -2410,6 +2591,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pause@0.0.1:
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -2915,10 +3099,18 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
v8-to-istanbul@9.3.0:
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
engines: {node: '>=10.12.0'}
validator@13.15.35:
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
engines: {node: '>= 0.10'}
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -3788,7 +3980,7 @@ snapshots:
- uglify-js
- webpack-cli
'@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)':
'@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
file-type: 21.3.4
iterare: 1.2.1
@@ -3797,20 +3989,23 @@ snapshots:
rxjs: 7.8.2
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
transitivePeerDependencies:
- supports-color
'@nestjs/config@4.0.4(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
'@nestjs/config@4.0.4(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/common': 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
dotenv: 17.4.1
dotenv-expand: 12.0.3
lodash: 4.18.1
rxjs: 7.8.2
'@nestjs/core@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
'@nestjs/core@11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/common': 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nuxt/opencollective': 0.4.1
fast-safe-stringify: 2.1.1
iterare: 1.2.1
@@ -3820,12 +4015,23 @@ snapshots:
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
'@nestjs/platform-express': 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/platform-express': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/platform-express@11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)':
'@nestjs/jwt@11.0.2(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/common': 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@types/jsonwebtoken': 9.0.10
jsonwebtoken: 9.0.3
'@nestjs/passport@11.0.5(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)':
dependencies:
'@nestjs/common': 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
passport: 0.7.0
'@nestjs/platform-express@11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)':
dependencies:
'@nestjs/common': 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cors: 2.8.6
express: 5.2.1
multer: 2.1.1
@@ -3845,13 +4051,19 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/testing@11.1.24(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)':
'@nestjs/testing@11.1.24(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)':
dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/common': 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
optionalDependencies:
'@nestjs/platform-express': 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/platform-express': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.21(@nestjs/common@11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@next/env@15.5.18': {}
@@ -3879,10 +4091,39 @@ snapshots:
'@next/swc-win32-x64-msvc@15.5.18':
optional: true
'@noble/hashes@2.2.0': {}
'@nuxt/opencollective@0.4.1':
dependencies:
consola: 3.4.2
'@otplib/core@13.4.1': {}
'@otplib/hotp@13.4.1':
dependencies:
'@otplib/core': 13.4.1
'@otplib/uri': 13.4.1
'@otplib/plugin-base32-scure@13.4.1':
dependencies:
'@otplib/core': 13.4.1
'@scure/base': 2.2.0
'@otplib/plugin-crypto-noble@13.4.1':
dependencies:
'@noble/hashes': 2.2.0
'@otplib/core': 13.4.1
'@otplib/totp@13.4.1':
dependencies:
'@otplib/core': 13.4.1
'@otplib/hotp': 13.4.1
'@otplib/uri': 13.4.1
'@otplib/uri@13.4.1':
dependencies:
'@otplib/core': 13.4.1
'@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)':
optionalDependencies:
prisma: 6.19.3(typescript@5.9.3)
@@ -3918,6 +4159,8 @@ snapshots:
dependencies:
'@prisma/debug': 6.19.3
'@scure/base@2.2.0': {}
'@sinclair/typebox@0.27.10': {}
'@sinonjs/commons@3.0.1':
@@ -3982,6 +4225,10 @@ snapshots:
dependencies:
'@babel/types': 7.29.7
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 22.19.19
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -4037,12 +4284,35 @@ snapshots:
expect: 29.7.0
pretty-format: 29.7.0
'@types/js-cookie@3.0.6': {}
'@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 22.19.19
'@types/ms@2.1.0': {}
'@types/node@22.19.19':
dependencies:
undici-types: 6.21.0
'@types/passport-jwt@4.0.1':
dependencies:
'@types/jsonwebtoken': 9.0.10
'@types/passport-strategy': 0.2.38
'@types/passport-strategy@0.2.38':
dependencies:
'@types/express': 5.0.6
'@types/passport': 1.0.17
'@types/passport@1.0.17':
dependencies:
'@types/express': 5.0.6
'@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {}
@@ -4066,6 +4336,8 @@ snapshots:
'@types/stack-utils@2.0.3': {}
'@types/validator@13.15.10': {}
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.35':
@@ -4295,6 +4567,11 @@ snapshots:
baseline-browser-mapping@2.10.31: {}
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.8.0
node-gyp-build: 4.8.4
bl@4.1.0:
dependencies:
buffer: 5.7.1
@@ -4344,6 +4621,8 @@ snapshots:
dependencies:
node-int64: 0.4.0
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@5.7.1:
@@ -4415,6 +4694,14 @@ snapshots:
cjs-module-lexer@1.4.3: {}
class-transformer@0.5.1: {}
class-validator@0.15.1:
dependencies:
'@types/validator': 13.15.10
libphonenumber-js: 1.13.4
validator: 13.15.35
cli-cursor@3.1.0:
dependencies:
restore-cursor: 3.1.0
@@ -4561,6 +4848,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
effect@3.21.0:
@@ -5282,6 +5573,8 @@ snapshots:
jiti@2.7.0: {}
js-cookie@3.0.8: {}
js-tokens@4.0.0: {}
js-yaml@3.14.2:
@@ -5311,10 +5604,36 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.8.0
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
kleur@3.0.3: {}
leven@3.1.0: {}
libphonenumber-js@1.13.4: {}
lines-and-columns@1.2.4: {}
load-esm@1.0.3: {}
@@ -5325,8 +5644,22 @@ snapshots:
dependencies:
p-locate: 4.1.0
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.memoize@4.1.2: {}
lodash.once@4.1.1: {}
lodash@4.18.1: {}
log-symbols@4.1.0:
@@ -5443,12 +5776,16 @@ snapshots:
node-abort-controller@3.1.1: {}
node-addon-api@8.8.0: {}
node-emoji@1.11.0:
dependencies:
lodash: 4.18.1
node-fetch-native@1.6.7: {}
node-gyp-build@4.8.4: {}
node-int64@0.4.0: {}
node-releases@2.0.44: {}
@@ -5495,6 +5832,15 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
otplib@13.4.1:
dependencies:
'@otplib/core': 13.4.1
'@otplib/hotp': 13.4.1
'@otplib/plugin-base32-scure': 13.4.1
'@otplib/plugin-crypto-noble': 13.4.1
'@otplib/totp': 13.4.1
'@otplib/uri': 13.4.1
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@@ -5522,6 +5868,19 @@ snapshots:
parseurl@1.3.3: {}
passport-jwt@4.0.1:
dependencies:
jsonwebtoken: 9.0.3
passport-strategy: 1.0.0
passport-strategy@1.0.0: {}
passport@0.7.0:
dependencies:
passport-strategy: 1.0.0
pause: 0.0.1
utils-merge: 1.0.1
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
@@ -5541,6 +5900,8 @@ snapshots:
pathe@2.0.3: {}
pause@0.0.1: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
@@ -6014,12 +6375,16 @@ snapshots:
util-deprecate@1.0.2: {}
utils-merge@1.0.1: {}
v8-to-istanbul@9.3.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@types/istanbul-lib-coverage': 2.0.6
convert-source-map: 2.0.0
validator@13.15.35: {}
vary@1.1.2: {}
walker@1.0.8: