Update pnpm-lock.yaml and Refactor BFF Order Management
- Updated pnpm-lock.yaml to standardize quotes and improve consistency across dependencies. - Added @nestjs/swagger and swagger-ui-express to the BFF package.json for enhanced API documentation. - Refactored notifications service to utilize Prisma types for better type safety. - Removed orders controller, DTO, and orchestrator service to streamline order management functionality. - Enhanced next.config.mjs to support API proxying in development, improving local development experience.
This commit is contained in:
parent
87766fb1d5
commit
f775a62c64
@ -39,6 +39,7 @@
|
||||
"@nestjs/core": "^11.1.9",
|
||||
"@nestjs/platform-express": "^11.1.9",
|
||||
"@nestjs/schedule": "^6.1.0",
|
||||
"@nestjs/swagger": "^11.2.3",
|
||||
"@prisma/adapter-pg": "^7.1.0",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"@sendgrid/mail": "^8.1.6",
|
||||
@ -59,6 +60,7 @@
|
||||
"rxjs": "^7.8.2",
|
||||
"salesforce-pubsub-api-client": "^5.5.1",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||
import type { Notification as PrismaNotification } from "@prisma/client";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import {
|
||||
NOTIFICATION_SOURCE,
|
||||
@ -163,7 +164,7 @@ export class NotificationService {
|
||||
]);
|
||||
|
||||
return {
|
||||
notifications: notifications.map(n => this.mapToNotification(n)),
|
||||
notifications: notifications.map((n: PrismaNotification) => this.mapToNotification(n)),
|
||||
unreadCount,
|
||||
total,
|
||||
};
|
||||
|
||||
@ -1,123 +0,0 @@
|
||||
import {
|
||||
IsString,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsObject,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class OrderConfigurations {
|
||||
// Activation (All order types)
|
||||
@IsOptional()
|
||||
@IsIn(["Immediate", "Scheduled"])
|
||||
activationType?: "Immediate" | "Scheduled";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scheduledAt?: string;
|
||||
|
||||
// Internet specific
|
||||
@IsOptional()
|
||||
@IsIn(["IPoE-BYOR", "IPoE-HGW", "PPPoE"])
|
||||
accessMode?: "IPoE-BYOR" | "IPoE-HGW" | "PPPoE";
|
||||
|
||||
// SIM specific
|
||||
@IsOptional()
|
||||
@IsIn(["eSIM", "Physical SIM"])
|
||||
simType?: "eSIM" | "Physical SIM";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
eid?: string; // Required for eSIM
|
||||
|
||||
// MNP/Porting
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
isMnp?: string; // "true" | "false"
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mnpNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mnpExpiry?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mnpPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mvnoAccountNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
portingLastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
portingFirstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
portingLastNameKatakana?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
portingFirstNameKatakana?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["Male", "Female", "Corporate/Other"])
|
||||
portingGender?: "Male" | "Female" | "Corporate/Other";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
portingDateOfBirth?: string;
|
||||
|
||||
// Address (when address is updated during checkout)
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
address?: {
|
||||
street?: string | null;
|
||||
streetLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
};
|
||||
|
||||
// VPN region is inferred from product VPN_Region__c field, no user input needed
|
||||
}
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(["Internet", "SIM", "VPN", "Other"])
|
||||
orderType: "Internet" | "SIM" | "VPN" | "Other";
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
skus: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => OrderConfigurations)
|
||||
configurations?: OrderConfigurations;
|
||||
}
|
||||
|
||||
// Interface for service layer (extends DTO with additional fields)
|
||||
export interface CreateOrderBody extends Omit<CreateOrderDto, "configurations"> {
|
||||
configurations?: OrderConfigurations;
|
||||
opportunityId?: string; // Additional field for internal use
|
||||
}
|
||||
|
||||
export interface UserMapping {
|
||||
sfAccountId: string;
|
||||
whmcsClientId: number;
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
import { Body, Controller, Get, Param, Post, Request } from "@nestjs/common";
|
||||
import { OrderOrchestrator } from "./services/order-orchestrator.service";
|
||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { RequestWithUser } from "../auth/auth.types";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CreateOrderDto } from "./dto/order.dto";
|
||||
|
||||
@ApiTags("orders")
|
||||
@Controller("orders")
|
||||
export class OrdersController {
|
||||
constructor(
|
||||
private orderOrchestrator: OrderOrchestrator,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create Salesforce Order" })
|
||||
@ApiResponse({ status: 201, description: "Order created successfully" })
|
||||
@ApiResponse({ status: 400, description: "Invalid request data" })
|
||||
async create(@Request() req: RequestWithUser, @Body() body: CreateOrderDto) {
|
||||
this.logger.log(
|
||||
{
|
||||
userId: req.user?.id,
|
||||
orderType: body.orderType,
|
||||
skuCount: body.skus?.length || 0,
|
||||
requestBody: JSON.stringify(body, null, 2),
|
||||
},
|
||||
"Order creation request received"
|
||||
);
|
||||
|
||||
try {
|
||||
return await this.orderOrchestrator.createOrder(req.user.id, body);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
userId: req.user?.id,
|
||||
orderType: body.orderType,
|
||||
fullRequestBody: JSON.stringify(body, null, 2),
|
||||
},
|
||||
"Order creation failed"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Get("user")
|
||||
@ApiOperation({ summary: "Get user's orders" })
|
||||
async getUserOrders(@Request() req: RequestWithUser) {
|
||||
return this.orderOrchestrator.getOrdersForUser(req.user.id);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Get(":sfOrderId")
|
||||
@ApiOperation({ summary: "Get order summary/status" })
|
||||
@ApiParam({ name: "sfOrderId", type: String })
|
||||
async get(@Request() req: RequestWithUser, @Param("sfOrderId") sfOrderId: string) {
|
||||
return this.orderOrchestrator.getOrder(sfOrderId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Post(":sfOrderId/provision")
|
||||
@ApiOperation({ summary: "Trigger provisioning for an approved order" })
|
||||
@ApiParam({ name: "sfOrderId", type: String })
|
||||
async provision(@Request() req: RequestWithUser, @Param("sfOrderId") sfOrderId: string) {
|
||||
return this.orderOrchestrator.provisionOrder(req.user.id, sfOrderId);
|
||||
}
|
||||
}
|
||||
@ -1,315 +0,0 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "../../vendors/salesforce/services/salesforce-connection.service";
|
||||
import { OrderValidator } from "./order-validator.service";
|
||||
import { OrderBuilder } from "./order-builder.service";
|
||||
import { OrderItemBuilder } from "./order-item-builder.service";
|
||||
import {
|
||||
SalesforceOrder,
|
||||
SalesforceOrderItem,
|
||||
SalesforceQueryResult,
|
||||
} from "../types/salesforce-order.types";
|
||||
import { getSalesforceFieldMap } from "../../common/config/field-map";
|
||||
|
||||
/**
|
||||
* Main orchestrator for order operations
|
||||
* Coordinates all order-related services
|
||||
*/
|
||||
@Injectable()
|
||||
export class OrderOrchestrator {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly sf: SalesforceConnection,
|
||||
private readonly orderValidator: OrderValidator,
|
||||
private readonly orderBuilder: OrderBuilder,
|
||||
private readonly orderItemBuilder: OrderItemBuilder
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new order - main entry point
|
||||
*/
|
||||
async createOrder(userId: string, rawBody: unknown) {
|
||||
this.logger.log({ userId }, "Order creation workflow started");
|
||||
|
||||
// 1) Complete validation (format + business rules)
|
||||
const { validatedBody, userMapping, pricebookId } =
|
||||
await this.orderValidator.validateCompleteOrder(userId, rawBody);
|
||||
|
||||
this.logger.log(
|
||||
{
|
||||
userId,
|
||||
orderType: validatedBody.orderType,
|
||||
skuCount: validatedBody.skus.length,
|
||||
},
|
||||
"Order validation completed successfully"
|
||||
);
|
||||
|
||||
// 2) Build order fields (includes address snapshot)
|
||||
const orderFields = await this.orderBuilder.buildOrderFields(
|
||||
validatedBody,
|
||||
userMapping,
|
||||
pricebookId,
|
||||
userId
|
||||
);
|
||||
|
||||
this.logger.log({ orderType: orderFields.Type }, "Creating Salesforce Order");
|
||||
|
||||
// 4) Create Order in Salesforce
|
||||
let created: { id: string };
|
||||
try {
|
||||
this.logger.log(
|
||||
{
|
||||
orderFields: JSON.stringify(orderFields, null, 2),
|
||||
fieldsCount: Object.keys(orderFields).length
|
||||
},
|
||||
"About to create Salesforce Order with fields"
|
||||
);
|
||||
|
||||
created = (await this.sf.sobject("Order").create(orderFields)) as { id: string };
|
||||
this.logger.log({ orderId: created.id }, "Salesforce Order created successfully");
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorDetails: error,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
orderType: orderFields.Type,
|
||||
orderFields: JSON.stringify(orderFields, null, 2),
|
||||
},
|
||||
"Failed to create Salesforce Order"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 5) Create OrderItems from SKUs
|
||||
await this.orderItemBuilder.createOrderItemsFromSKUs(
|
||||
created.id,
|
||||
validatedBody.skus,
|
||||
pricebookId
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
{
|
||||
orderId: created.id,
|
||||
skuCount: validatedBody.skus.length,
|
||||
},
|
||||
"Order creation workflow completed successfully"
|
||||
);
|
||||
|
||||
return {
|
||||
sfOrderId: created.id,
|
||||
status: "Created",
|
||||
message: "Order created successfully in Salesforce",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order by ID with order items
|
||||
*/
|
||||
async getOrder(orderId: string) {
|
||||
this.logger.log({ orderId }, "Fetching order details with items");
|
||||
|
||||
const fields = getSalesforceFieldMap();
|
||||
const orderSoql = `
|
||||
SELECT Id, OrderNumber, Status, ${fields.order.orderType}, EffectiveDate, TotalAmount,
|
||||
Account.Name, CreatedDate, LastModifiedDate,
|
||||
Activation_Type__c, Activation_Status__c, Activation_Scheduled_At__c,
|
||||
WHMCS_Order_ID__c
|
||||
FROM Order
|
||||
WHERE Id = '${orderId}'
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const orderItemsSoql = `
|
||||
SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice,
|
||||
PricebookEntry.Id,
|
||||
PricebookEntry.Product2.Id,
|
||||
PricebookEntry.Product2.Name,
|
||||
PricebookEntry.Product2.StockKeepingUnit,
|
||||
PricebookEntry.Product2.WH_Product_ID__c,
|
||||
PricebookEntry.Product2.Item_Class__c,
|
||||
PricebookEntry.Product2.Billing_Cycle__c
|
||||
FROM OrderItem
|
||||
WHERE OrderId = '${orderId}'
|
||||
ORDER BY CreatedDate ASC
|
||||
`;
|
||||
|
||||
try {
|
||||
const [orderResult, itemsResult] = await Promise.all([
|
||||
this.sf.query(orderSoql) as Promise<SalesforceQueryResult<SalesforceOrder>>,
|
||||
this.sf.query(orderItemsSoql) as Promise<SalesforceQueryResult<SalesforceOrderItem>>,
|
||||
]);
|
||||
|
||||
const order = orderResult.records?.[0];
|
||||
|
||||
if (!order) {
|
||||
this.logger.warn({ orderId }, "Order not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
const orderItems = (itemsResult.records || []).map((item: SalesforceOrderItem) => ({
|
||||
id: item.Id,
|
||||
quantity: item.Quantity,
|
||||
unitPrice: item.UnitPrice,
|
||||
totalPrice: item.TotalPrice,
|
||||
product: {
|
||||
id: item.PricebookEntry?.Product2?.Id,
|
||||
name: item.PricebookEntry?.Product2?.Name,
|
||||
sku: String(item.PricebookEntry?.Product2?.StockKeepingUnit || ""), // This is the key field that was missing!
|
||||
whmcsProductId: String(item.PricebookEntry?.Product2?.WH_Product_ID__c || ""),
|
||||
itemClass: String(item.PricebookEntry?.Product2?.Item_Class__c || ""),
|
||||
billingCycle: String(item.PricebookEntry?.Product2?.Billing_Cycle__c || ""),
|
||||
},
|
||||
}));
|
||||
|
||||
this.logger.log(
|
||||
{ orderId, itemCount: orderItems.length },
|
||||
"Order details retrieved with items"
|
||||
);
|
||||
|
||||
return {
|
||||
id: order.Id,
|
||||
orderNumber: order.OrderNumber,
|
||||
status: order.Status,
|
||||
orderType: order.Type,
|
||||
effectiveDate: order.EffectiveDate,
|
||||
totalAmount: order.TotalAmount,
|
||||
accountName: order.Account?.Name,
|
||||
createdDate: order.CreatedDate,
|
||||
lastModifiedDate: order.LastModifiedDate,
|
||||
activationType: order.Activation_Type__c,
|
||||
activationStatus: order.Activation_Status__c,
|
||||
scheduledAt: order.Activation_Scheduled_At__c,
|
||||
whmcsOrderId: order.WHMCS_Order_ID__c,
|
||||
items: orderItems, // Now includes all the product details with SKUs!
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error({ error, orderId }, "Failed to fetch order with items");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders for a user with basic item summary
|
||||
*/
|
||||
async getOrdersForUser(userId: string) {
|
||||
this.logger.log({ userId }, "Fetching user orders with item summaries");
|
||||
|
||||
// Get user mapping
|
||||
const userMapping = await this.orderValidator.validateUserMapping(userId);
|
||||
|
||||
const fields = getSalesforceFieldMap();
|
||||
const ordersSoql = `
|
||||
SELECT Id, OrderNumber, Status, ${fields.order.orderType}, EffectiveDate, TotalAmount,
|
||||
CreatedDate, LastModifiedDate, WHMCS_Order_ID__c
|
||||
FROM Order
|
||||
WHERE AccountId = '${userMapping.sfAccountId}'
|
||||
ORDER BY CreatedDate DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
|
||||
try {
|
||||
const ordersResult = (await this.sf.query(
|
||||
ordersSoql
|
||||
)) as SalesforceQueryResult<SalesforceOrder>;
|
||||
const orders = ordersResult.records || [];
|
||||
|
||||
if (orders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get order items for all orders in one query
|
||||
const orderIds = orders.map(o => `'${o.Id}'`).join(",");
|
||||
const itemsSoql = `
|
||||
SELECT Id, OrderId, Quantity,
|
||||
PricebookEntry.Product2.Name,
|
||||
PricebookEntry.Product2.StockKeepingUnit,
|
||||
PricebookEntry.Product2.Item_Class__c
|
||||
FROM OrderItem
|
||||
WHERE OrderId IN (${orderIds})
|
||||
ORDER BY OrderId, CreatedDate ASC
|
||||
`;
|
||||
|
||||
const itemsResult = (await this.sf.query(
|
||||
itemsSoql
|
||||
)) as SalesforceQueryResult<SalesforceOrderItem>;
|
||||
const allItems = itemsResult.records || [];
|
||||
|
||||
// Group items by order ID
|
||||
const itemsByOrder = allItems.reduce(
|
||||
(acc, item: SalesforceOrderItem) => {
|
||||
if (!acc[item.OrderId]) acc[item.OrderId] = [];
|
||||
acc[item.OrderId].push({
|
||||
name: String(item.PricebookEntry?.Product2?.Name || ""),
|
||||
sku: String(item.PricebookEntry?.Product2?.StockKeepingUnit || ""),
|
||||
itemClass: String(item.PricebookEntry?.Product2?.Item_Class__c || ""),
|
||||
quantity: item.Quantity,
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
Array<{
|
||||
name?: string;
|
||||
sku?: string;
|
||||
itemClass?: string;
|
||||
quantity: number;
|
||||
}>
|
||||
>
|
||||
);
|
||||
|
||||
return orders.map((order: SalesforceOrder) => ({
|
||||
id: order.Id,
|
||||
orderNumber: order.OrderNumber,
|
||||
status: order.Status,
|
||||
orderType: order.Type,
|
||||
effectiveDate: order.EffectiveDate,
|
||||
totalAmount: order.TotalAmount,
|
||||
createdDate: order.CreatedDate,
|
||||
lastModifiedDate: order.LastModifiedDate,
|
||||
whmcsOrderId: order.WHMCS_Order_ID__c,
|
||||
itemsSummary: itemsByOrder[order.Id] || [], // Include basic item info for order list
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error({ error, userId }, "Failed to fetch user orders with items");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger provisioning for an approved order
|
||||
*/
|
||||
async provisionOrder(userId: string, orderId: string) {
|
||||
this.logger.log({ userId, orderId }, "Triggering order provisioning");
|
||||
|
||||
// Get order and verify it belongs to the user
|
||||
const order = await this.getOrder(orderId);
|
||||
if (!order) {
|
||||
this.logger.warn({ orderId, userId }, "Order not found for provisioning");
|
||||
throw new BadRequestException("Order not found");
|
||||
}
|
||||
|
||||
// For now, just update the order status to indicate provisioning has started
|
||||
// In a real implementation, this would trigger the actual provisioning workflow
|
||||
const soql = `
|
||||
UPDATE Order
|
||||
SET Status = 'Provisioning Started',
|
||||
Provisioning_Status__c = 'In Progress'
|
||||
WHERE Id = '${orderId}'
|
||||
`;
|
||||
|
||||
try {
|
||||
await this.sf.query(soql);
|
||||
this.logger.log({ orderId }, "Order provisioning triggered successfully");
|
||||
|
||||
return {
|
||||
orderId,
|
||||
status: "Provisioning Started",
|
||||
message: "Order provisioning has been initiated",
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error({ error, orderId }, "Failed to trigger order provisioning");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// BFF URL for development API proxying
|
||||
const BFF_URL = process.env.BFF_URL || "http://localhost:4000";
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
|
||||
@ -23,11 +27,24 @@ const nextConfig = {
|
||||
},
|
||||
|
||||
env: {
|
||||
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
|
||||
// In development, we use rewrites to proxy API calls, so API_BASE is same-origin
|
||||
// In production, API_BASE should be set via environment (nginx proxy or direct BFF URL)
|
||||
NEXT_PUBLIC_API_BASE: isDev ? "" : process.env.NEXT_PUBLIC_API_BASE,
|
||||
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
|
||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
||||
},
|
||||
|
||||
// Proxy API requests to BFF in development
|
||||
async rewrites() {
|
||||
if (!isDev) return [];
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${BFF_URL}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
|
||||
@ -1,584 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { PageLayout } from "@/components/layout/page-layout";
|
||||
import { ShieldCheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import { AddressConfirmation } from "@/components/checkout/address-confirmation";
|
||||
import { usePaymentMethods } from "@/hooks/useInvoices";
|
||||
|
||||
import {
|
||||
InternetPlan,
|
||||
InternetAddon,
|
||||
InternetInstallation,
|
||||
SimPlan,
|
||||
SimActivationFee,
|
||||
SimAddon,
|
||||
CheckoutState,
|
||||
OrderItem,
|
||||
buildInternetOrderItems,
|
||||
buildSimOrderItems,
|
||||
calculateTotals,
|
||||
buildOrderSKUs,
|
||||
} from "@/shared/types/catalog.types";
|
||||
|
||||
interface Address {
|
||||
street: string | null;
|
||||
streetLine2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
function CheckoutContent() {
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
||||
const [confirmedAddress, setConfirmedAddress] = useState<Address | null>(null);
|
||||
const [forceUpdate, setForceUpdate] = useState(0);
|
||||
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
|
||||
loading: true,
|
||||
error: null,
|
||||
orderItems: [],
|
||||
totals: { monthlyTotal: 0, oneTimeTotal: 0 },
|
||||
});
|
||||
|
||||
// Fetch payment methods to check if user has payment method on file
|
||||
const { data: paymentMethods, isLoading: paymentMethodsLoading, error: paymentMethodsError, refetch: refetchPaymentMethods } = usePaymentMethods();
|
||||
|
||||
const orderType = (() => {
|
||||
const type = params.get("type") || "internet";
|
||||
// Map to backend expected values
|
||||
switch (type.toLowerCase()) {
|
||||
case "sim":
|
||||
return "SIM";
|
||||
case "internet":
|
||||
return "Internet";
|
||||
case "vpn":
|
||||
return "VPN";
|
||||
default:
|
||||
return "Other";
|
||||
}
|
||||
})();
|
||||
|
||||
const selections = useMemo(() => {
|
||||
const obj: Record<string, string> = {};
|
||||
params.forEach((v, k) => {
|
||||
if (k !== "type") obj[k] = v;
|
||||
});
|
||||
return obj;
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void (async () => {
|
||||
try {
|
||||
setCheckoutState(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
// Validate required parameters
|
||||
if (!selections.plan) {
|
||||
throw new Error("No plan selected. Please go back and select a plan.");
|
||||
}
|
||||
|
||||
let orderItems: OrderItem[] = [];
|
||||
|
||||
if (orderType === "Internet") {
|
||||
// Fetch Internet data
|
||||
const [plans, addons, installations] = await Promise.all([
|
||||
authenticatedApi.get<InternetPlan[]>("/catalog/internet/plans"),
|
||||
authenticatedApi.get<InternetAddon[]>("/catalog/internet/addons"),
|
||||
authenticatedApi.get<InternetInstallation[]>("/catalog/internet/installations"),
|
||||
]);
|
||||
|
||||
const plan = plans.find(p => p.sku === selections.plan);
|
||||
if (!plan) {
|
||||
throw new Error(
|
||||
`Internet plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`
|
||||
);
|
||||
}
|
||||
|
||||
// Handle addon SKUs like SIM flow
|
||||
const addonSkus: string[] = [];
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.getAll("addonSku").forEach(sku => {
|
||||
if (sku && !addonSkus.includes(sku)) {
|
||||
addonSkus.push(sku);
|
||||
}
|
||||
});
|
||||
|
||||
orderItems = buildInternetOrderItems(plan, addons, installations, {
|
||||
installationSku: selections.installationSku,
|
||||
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
|
||||
});
|
||||
} else if (orderType === "SIM") {
|
||||
// Fetch SIM data
|
||||
const [plans, activationFees, addons] = await Promise.all([
|
||||
authenticatedApi.get<SimPlan[]>("/catalog/sim/plans"),
|
||||
authenticatedApi.get<SimActivationFee[]>("/catalog/sim/activation-fees"),
|
||||
authenticatedApi.get<SimAddon[]>("/catalog/sim/addons"),
|
||||
]);
|
||||
|
||||
const plan = plans.find(p => p.sku === selections.plan); // Look up by SKU instead of ID
|
||||
if (!plan) {
|
||||
throw new Error(
|
||||
`SIM plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`
|
||||
);
|
||||
}
|
||||
// Handle multiple addons from URL parameters
|
||||
const addonSkus: string[] = [];
|
||||
if (selections.addonSku) {
|
||||
// Single addon (legacy support)
|
||||
addonSkus.push(selections.addonSku);
|
||||
}
|
||||
// Check for multiple addonSku parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.getAll("addonSku").forEach(sku => {
|
||||
if (sku && !addonSkus.includes(sku)) {
|
||||
addonSkus.push(sku);
|
||||
}
|
||||
});
|
||||
|
||||
orderItems = buildSimOrderItems(plan, activationFees, addons, {
|
||||
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
const totals = calculateTotals(orderItems);
|
||||
setCheckoutState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
orderItems,
|
||||
totals,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
setCheckoutState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : "Failed to load checkout data",
|
||||
}));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [orderType, selections]);
|
||||
|
||||
// Debug effect to track addressConfirmed changes
|
||||
useEffect(() => {
|
||||
console.log("🎯 PARENT: addressConfirmed state changed to:", addressConfirmed);
|
||||
}, [addressConfirmed]);
|
||||
|
||||
const handleSubmitOrder = async () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
const skus = buildOrderSKUs(checkoutState.orderItems);
|
||||
|
||||
// Validate we have SKUs before proceeding
|
||||
if (!skus || skus.length === 0) {
|
||||
throw new Error("No products selected for order. Please go back and select products.");
|
||||
}
|
||||
|
||||
// Additional validation for Internet orders
|
||||
if (orderType === "Internet") {
|
||||
const hasServicePlan = checkoutState.orderItems.some(item => item.type === "service");
|
||||
const hasInstallation = checkoutState.orderItems.some(item => item.type === "installation");
|
||||
|
||||
console.log("🔍 Internet order validation:", {
|
||||
hasServicePlan,
|
||||
hasInstallation,
|
||||
orderItems: checkoutState.orderItems,
|
||||
selections: selections
|
||||
});
|
||||
|
||||
if (!hasServicePlan) {
|
||||
throw new Error("Internet service plan is required. Please go back and select a plan.");
|
||||
}
|
||||
|
||||
// Installation is typically required for Internet orders
|
||||
if (!hasInstallation) {
|
||||
console.warn("⚠️ No installation selected for Internet order - this might cause issues");
|
||||
}
|
||||
}
|
||||
|
||||
// Send SKUs + configurations - backend resolves product data from SKUs,
|
||||
// uses configurations for fields that cannot be inferred
|
||||
const configurations: Record<string, unknown> = {};
|
||||
|
||||
// Extract configurations from URL params (these come from configure pages)
|
||||
if (selections.accessMode) configurations.accessMode = selections.accessMode;
|
||||
if (selections.simType) configurations.simType = selections.simType;
|
||||
if (selections.eid) configurations.eid = selections.eid;
|
||||
// VPN region is inferred from product VPN_Region__c field, no configuration needed
|
||||
if (selections.activationType) configurations.activationType = selections.activationType;
|
||||
if (selections.scheduledAt) configurations.scheduledAt = selections.scheduledAt;
|
||||
|
||||
// MNP fields (must match backend field expectations exactly)
|
||||
if (selections.isMnp) configurations.isMnp = selections.isMnp;
|
||||
if (selections.reservationNumber) configurations.mnpNumber = selections.reservationNumber;
|
||||
if (selections.expiryDate) configurations.mnpExpiry = selections.expiryDate;
|
||||
if (selections.phoneNumber) configurations.mnpPhone = selections.phoneNumber;
|
||||
if (selections.mvnoAccountNumber)
|
||||
configurations.mvnoAccountNumber = selections.mvnoAccountNumber;
|
||||
if (selections.portingLastName) configurations.portingLastName = selections.portingLastName;
|
||||
if (selections.portingFirstName)
|
||||
configurations.portingFirstName = selections.portingFirstName;
|
||||
if (selections.portingLastNameKatakana)
|
||||
configurations.portingLastNameKatakana = selections.portingLastNameKatakana;
|
||||
if (selections.portingFirstNameKatakana)
|
||||
configurations.portingFirstNameKatakana = selections.portingFirstNameKatakana;
|
||||
if (selections.portingGender) configurations.portingGender = selections.portingGender;
|
||||
if (selections.portingDateOfBirth)
|
||||
configurations.portingDateOfBirth = selections.portingDateOfBirth;
|
||||
|
||||
// Include address in configurations if it was updated during checkout
|
||||
if (confirmedAddress) {
|
||||
configurations.address = confirmedAddress;
|
||||
}
|
||||
|
||||
const orderData = {
|
||||
orderType,
|
||||
skus: skus,
|
||||
...(Object.keys(configurations).length > 0 && { configurations }),
|
||||
};
|
||||
|
||||
console.log("🚀 Submitting order with data:", JSON.stringify(orderData, null, 2));
|
||||
console.log("🚀 Address confirmed state:", addressConfirmed);
|
||||
console.log("🚀 Confirmed address:", confirmedAddress);
|
||||
console.log("🚀 Order type:", orderType);
|
||||
console.log("🚀 SKUs:", skus);
|
||||
|
||||
const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);
|
||||
router.push(`/orders/${response.sfOrderId}?status=success`);
|
||||
} catch (error) {
|
||||
console.error("🚨 Order submission failed:", error);
|
||||
|
||||
// Enhanced error logging for debugging
|
||||
if (error instanceof Error) {
|
||||
console.error("🚨 Error name:", error.name);
|
||||
console.error("🚨 Error message:", error.message);
|
||||
console.error("🚨 Error stack:", error.stack);
|
||||
}
|
||||
|
||||
// If it's an API error, try to get more details
|
||||
if (error && typeof error === 'object' && 'status' in error) {
|
||||
console.error("🚨 HTTP Status:", error.status);
|
||||
console.error("🚨 Error details:", error);
|
||||
}
|
||||
|
||||
let errorMessage = "Order submission failed";
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
setCheckoutState(prev => ({
|
||||
...prev,
|
||||
error: errorMessage,
|
||||
}));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressConfirmed = useCallback((address?: Address) => {
|
||||
console.log("🎯 PARENT: handleAddressConfirmed called with:", address);
|
||||
console.log("🎯 PARENT: Current addressConfirmed state before:", addressConfirmed);
|
||||
|
||||
console.log("🎯 PARENT: About to call setAddressConfirmed(true)...");
|
||||
setAddressConfirmed(prev => {
|
||||
console.log("🎯 PARENT: setAddressConfirmed functional update - prev:", prev, "-> true");
|
||||
return true;
|
||||
});
|
||||
console.log("🎯 PARENT: setAddressConfirmed(true) called");
|
||||
|
||||
console.log("🎯 PARENT: About to call setConfirmedAddress...");
|
||||
setConfirmedAddress(address || null);
|
||||
console.log("🎯 PARENT: setConfirmedAddress called");
|
||||
|
||||
// Force a re-render to ensure the UI updates
|
||||
setForceUpdate(prev => prev + 1);
|
||||
console.log("🎯 PARENT: Force update triggered");
|
||||
}, [addressConfirmed]);
|
||||
|
||||
const handleAddressIncomplete = () => {
|
||||
setAddressConfirmed(false);
|
||||
setConfirmedAddress(null);
|
||||
};
|
||||
|
||||
if (checkoutState.loading) {
|
||||
return (
|
||||
<PageLayout
|
||||
title="Submit Order"
|
||||
description="Loading order details"
|
||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||||
>
|
||||
<div className="text-center py-12">Loading order submission...</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (checkoutState.error) {
|
||||
return (
|
||||
<PageLayout
|
||||
title="Submit Order"
|
||||
description="Error loading order submission"
|
||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||||
>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 mb-4">{checkoutState.error}</p>
|
||||
<button onClick={() => router.back()} className="text-blue-600 hover:text-blue-800">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Submit Order"
|
||||
description="Submit your order for review and approval"
|
||||
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Address Confirmation */}
|
||||
<AddressConfirmation
|
||||
onAddressConfirmed={handleAddressConfirmed}
|
||||
onAddressIncomplete={handleAddressIncomplete}
|
||||
orderType={orderType}
|
||||
/>
|
||||
|
||||
{/* Order Submission Message */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-6 mb-6 text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<ShieldCheckIcon className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Submit Your Order for Review</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
You've configured your service and reviewed all details. Your order will be
|
||||
submitted for review and approval.
|
||||
</p>
|
||||
<div className="bg-white rounded-lg p-4 border border-blue-200">
|
||||
<h3 className="font-semibold text-gray-900 mb-2">What happens next?</h3>
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<p>• Your order will be reviewed by our team</p>
|
||||
<p>• We'll set up your services in our system</p>
|
||||
<p>• Payment will be processed using your card on file</p>
|
||||
<p>• You'll receive confirmation once everything is ready</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Totals Summary */}
|
||||
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium text-gray-700">Total:</span>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold text-gray-900">
|
||||
¥{checkoutState.totals.monthlyTotal.toLocaleString()}/mo
|
||||
</div>
|
||||
{checkoutState.totals.oneTimeTotal > 0 && (
|
||||
<div className="text-sm text-orange-600 font-medium">
|
||||
+ ¥{checkoutState.totals.oneTimeTotal.toLocaleString()} one-time
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Billing Information</h3>
|
||||
{paymentMethodsLoading ? (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-600 text-sm">Checking payment methods...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : paymentMethodsError ? (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-amber-800 text-sm font-medium">Unable to verify payment methods</p>
|
||||
<p className="text-amber-700 text-sm mt-1">
|
||||
We couldn't check your payment methods. If you just added a payment method, try refreshing.
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
// First try to refresh cache on backend
|
||||
await authenticatedApi.post('/invoices/payment-methods/refresh');
|
||||
console.log('Backend cache refreshed successfully');
|
||||
} catch (error) {
|
||||
console.warn('Backend cache refresh failed, using frontend refresh:', error);
|
||||
}
|
||||
|
||||
// Always refetch from frontend to get latest data
|
||||
try {
|
||||
await refetchPaymentMethods();
|
||||
console.log('Frontend cache refreshed successfully');
|
||||
} catch (error) {
|
||||
console.error('Frontend refresh also failed:', error);
|
||||
}
|
||||
}}
|
||||
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
Refresh Cache
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/billing/payments')}
|
||||
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add Payment Method
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-green-800 text-sm font-medium">Payment method verified</p>
|
||||
<p className="text-green-700 text-sm mt-1">
|
||||
After order approval, payment will be automatically processed using your existing
|
||||
payment method on file. No additional payment steps required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-800 text-sm font-medium">No payment method on file</p>
|
||||
<p className="text-red-700 text-sm mt-1">
|
||||
You need to add a payment method before submitting your order. Please add a credit card or other payment method to proceed.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/billing/payments')}
|
||||
className="mt-2 bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Add Payment Method
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Debug Info - Remove in production */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4 text-xs text-gray-700">
|
||||
<strong>Debug Info:</strong> Address Confirmed: {addressConfirmed ? '✅ TRUE' : '❌ FALSE'} |
|
||||
Order Type: {orderType} |
|
||||
Order Items: {checkoutState.orderItems.length} |
|
||||
Payment Methods: {paymentMethodsLoading ? '⏳ Loading...' : paymentMethodsError ? '❌ Error' : paymentMethods ? `✅ ${paymentMethods.paymentMethods.length} found` : '❌ None'} |
|
||||
Force Update: {forceUpdate} |
|
||||
Can Submit: {!(
|
||||
submitting ||
|
||||
checkoutState.orderItems.length === 0 ||
|
||||
!addressConfirmed ||
|
||||
paymentMethodsLoading ||
|
||||
!paymentMethods ||
|
||||
paymentMethods.paymentMethods.length === 0
|
||||
) ? '✅ YES' : '❌ NO'}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Construct the configure URL with current parameters to preserve data
|
||||
// Add step parameter to go directly to review step
|
||||
const urlParams = new URLSearchParams(params.toString());
|
||||
const reviewStep = orderType === "Internet" ? "4" : "5";
|
||||
urlParams.set("step", reviewStep);
|
||||
|
||||
const configureUrl =
|
||||
orderType === "Internet"
|
||||
? `/catalog/internet/configure?${urlParams.toString()}`
|
||||
: `/catalog/sim/configure?${urlParams.toString()}`;
|
||||
router.push(configureUrl);
|
||||
}}
|
||||
className="flex-1 px-6 py-4 border border-gray-300 rounded-lg text-center hover:bg-gray-50 transition-colors font-medium"
|
||||
>
|
||||
← Back to Review
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => void handleSubmitOrder()}
|
||||
disabled={
|
||||
submitting ||
|
||||
checkoutState.orderItems.length === 0 ||
|
||||
!addressConfirmed ||
|
||||
paymentMethodsLoading ||
|
||||
!paymentMethods ||
|
||||
paymentMethods.paymentMethods.length === 0
|
||||
}
|
||||
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
|
||||
>
|
||||
{submitting ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
Submitting Order...
|
||||
</span>
|
||||
) : !addressConfirmed ? (
|
||||
"📍 Complete Address to Continue"
|
||||
) : paymentMethodsLoading ? (
|
||||
"⏳ Verifying Payment Method..."
|
||||
) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? (
|
||||
"💳 Add Payment Method to Continue"
|
||||
) : (
|
||||
"📋 Submit Order for Review"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-12">Loading checkout...</div>}>
|
||||
<CheckoutContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@ -1,468 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import {
|
||||
MapPinIcon,
|
||||
PencilIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
interface Address {
|
||||
street: string | null;
|
||||
streetLine2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
interface BillingInfo {
|
||||
company: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
address: Address;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
interface AddressConfirmationProps {
|
||||
onAddressConfirmed: (address?: Address) => void;
|
||||
onAddressIncomplete: () => void;
|
||||
orderType?: string; // Add order type to customize behavior
|
||||
// Optional controlled props for parent state management
|
||||
addressConfirmed?: boolean; // If provided, use this instead of internal state
|
||||
onAddressConfirmationChange?: (confirmed: boolean) => void; // Callback for controlled mode
|
||||
}
|
||||
|
||||
export function AddressConfirmation({
|
||||
onAddressConfirmed,
|
||||
onAddressIncomplete,
|
||||
orderType,
|
||||
addressConfirmed: controlledAddressConfirmed,
|
||||
onAddressConfirmationChange,
|
||||
}: AddressConfirmationProps) {
|
||||
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editedAddress, setEditedAddress] = useState<Address | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [internalAddressConfirmed, setInternalAddressConfirmed] = useState(false);
|
||||
|
||||
// Use controlled prop if provided, otherwise use internal state
|
||||
const addressConfirmed = controlledAddressConfirmed ?? internalAddressConfirmed;
|
||||
const setAddressConfirmed = (value: boolean | ((prev: boolean) => boolean)) => {
|
||||
const newValue = typeof value === 'function' ? value(addressConfirmed) : value;
|
||||
|
||||
if (controlledAddressConfirmed !== undefined && onAddressConfirmationChange) {
|
||||
// Controlled mode: notify parent
|
||||
onAddressConfirmationChange(newValue);
|
||||
} else {
|
||||
// Uncontrolled mode: update internal state
|
||||
setInternalAddressConfirmed(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const isInternetOrder = orderType === "Internet";
|
||||
const requiresAddressVerification = isInternetOrder;
|
||||
|
||||
const fetchBillingInfo = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await authenticatedApi.get<BillingInfo>("/me/billing");
|
||||
setBillingInfo(data);
|
||||
|
||||
// Since address is required at signup, it should always be complete
|
||||
// But we still need verification for Internet orders
|
||||
if (requiresAddressVerification) {
|
||||
// For Internet orders, only reset confirmation state if not already confirmed
|
||||
// This prevents clobbering existing confirmation on re-renders/re-fetches
|
||||
if (!addressConfirmed) {
|
||||
console.log("🏠 Internet order: Setting initial unconfirmed state");
|
||||
setAddressConfirmed(false);
|
||||
onAddressIncomplete(); // Keep disabled until explicitly confirmed
|
||||
} else {
|
||||
console.log("🏠 Internet order: Preserving existing confirmation state");
|
||||
// Address is already confirmed, don't clobber the state
|
||||
}
|
||||
} else {
|
||||
// For other order types, auto-confirm since address exists from signup
|
||||
// Only call parent callback if we're not already confirmed to avoid spam
|
||||
if (!addressConfirmed) {
|
||||
console.log("🏠 Non-Internet order: Auto-confirming address");
|
||||
onAddressConfirmed(data.address);
|
||||
setAddressConfirmed(true);
|
||||
} else {
|
||||
console.log("🏠 Non-Internet order: Already confirmed, skipping callback");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load address");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed, addressConfirmed]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchBillingInfo();
|
||||
}, [fetchBillingInfo]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditing(true);
|
||||
setEditedAddress(
|
||||
billingInfo?.address || {
|
||||
street: "",
|
||||
streetLine2: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postalCode: "",
|
||||
country: "",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editedAddress) return;
|
||||
|
||||
// Validate required fields
|
||||
const isComplete = !!(
|
||||
editedAddress.street?.trim() &&
|
||||
editedAddress.city?.trim() &&
|
||||
editedAddress.state?.trim() &&
|
||||
editedAddress.postalCode?.trim() &&
|
||||
editedAddress.country?.trim()
|
||||
);
|
||||
|
||||
if (!isComplete) {
|
||||
setError("Please fill in all required address fields");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// UX-FIRST: Update UI immediately
|
||||
setEditing(false);
|
||||
setAddressConfirmed(true);
|
||||
|
||||
// Update local state to show the new address
|
||||
if (billingInfo) {
|
||||
setBillingInfo({
|
||||
...billingInfo,
|
||||
address: editedAddress,
|
||||
isComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
// SIDE-EFFECT SECOND: Use the edited address for the order (will be flagged as changed)
|
||||
onAddressConfirmed(editedAddress);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update address");
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmAddress = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// Prevent any default behavior and event propagation
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
console.log("🏠 CONFIRM ADDRESS CLICKED", {
|
||||
billingInfo,
|
||||
hasAddress: !!billingInfo?.address,
|
||||
address: billingInfo?.address,
|
||||
currentAddressConfirmed: addressConfirmed
|
||||
});
|
||||
|
||||
if (billingInfo?.address) {
|
||||
console.log("🏠 UX-First approach: Updating local state immediately for instant UI feedback");
|
||||
|
||||
// UX-FIRST: Update local state immediately for instant UI response
|
||||
setAddressConfirmed(true);
|
||||
console.log("🏠 ✅ Local addressConfirmed set to true (UI will update immediately)");
|
||||
|
||||
// SIDE-EFFECT SECOND: Notify parent after local state update
|
||||
console.log("🏠 Notifying parent component...");
|
||||
onAddressConfirmed(billingInfo.address);
|
||||
console.log("🏠 ✅ Parent onAddressConfirmed() called with:", billingInfo.address);
|
||||
} else {
|
||||
console.log("🏠 ❌ No billing info or address available");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false);
|
||||
setEditedAddress(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-600">Loading address information...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-red-800">Address Error</h3>
|
||||
<p className="text-sm text-red-700 mt-1">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchBillingInfo()}
|
||||
className="text-sm text-red-600 hover:text-red-500 font-medium mt-2"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!billingInfo) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-xl p-6 mb-6">
|
||||
{/* Debug Info - Remove in production */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4 text-xs text-gray-700">
|
||||
<strong>AddressConfirmation Debug:</strong> isInternetOrder: {isInternetOrder ? '✅' : '❌'} |
|
||||
addressConfirmed: {addressConfirmed ? '✅' : '❌'} |
|
||||
controlledMode: {controlledAddressConfirmed !== undefined ? '✅' : '❌'} |
|
||||
billingInfo: {billingInfo ? '✅' : '❌'} |
|
||||
hasAddress: {billingInfo?.address ? '✅' : '❌'} |
|
||||
showConfirmButton: {(isInternetOrder && !addressConfirmed) ? '✅' : '❌'}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<MapPinIcon className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{isInternetOrder
|
||||
? "Verify Installation Address"
|
||||
: billingInfo.isComplete
|
||||
? "Confirm Service Address"
|
||||
: "Complete Your Address"}
|
||||
</h3>
|
||||
</div>
|
||||
{billingInfo.isComplete && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="flex items-center space-x-2 text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address should always be complete since it's required at signup */}
|
||||
|
||||
{isInternetOrder && !addressConfirmed && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<MapPinIcon className="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Internet Installation Address Verification Required</strong>
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Please verify this is the correct address for your internet installation. A
|
||||
technician will visit this location for setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.street || ""}
|
||||
onChange={e => {
|
||||
setError(null); // Clear error on input
|
||||
setEditedAddress(prev => (prev ? { ...prev, street: e.target.value } : null));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="123 Main Street"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Street Address Line 2
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.streetLine2 || ""}
|
||||
onChange={e => {
|
||||
setError(null);
|
||||
setEditedAddress(prev => (prev ? { ...prev, streetLine2: e.target.value } : null));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Apartment, suite, etc. (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.city || ""}
|
||||
onChange={e => {
|
||||
setError(null);
|
||||
setEditedAddress(prev => (prev ? { ...prev, city: e.target.value } : null));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Tokyo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
State/Prefecture *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.state || ""}
|
||||
onChange={e => {
|
||||
setError(null);
|
||||
setEditedAddress(prev => (prev ? { ...prev, state: e.target.value } : null));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Tokyo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Postal Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.postalCode || ""}
|
||||
onChange={e => {
|
||||
setError(null);
|
||||
setEditedAddress(prev => (prev ? { ...prev, postalCode: e.target.value } : null));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="100-0001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Country *</label>
|
||||
<select
|
||||
value={editedAddress?.country || ""}
|
||||
onChange={e => {
|
||||
setError(null);
|
||||
setEditedAddress(prev => (prev ? { ...prev, country: e.target.value } : null));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select Country</option>
|
||||
<option value="JP">Japan</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="AU">Australia</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
<span>Save Address</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="flex items-center space-x-2 bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{billingInfo.address.street ? (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-gray-900">
|
||||
<p className="font-medium">{billingInfo.address.street}</p>
|
||||
{billingInfo.address.streetLine2 && <p>{billingInfo.address.streetLine2}</p>}
|
||||
<p>
|
||||
{billingInfo.address.city}, {billingInfo.address.state}{" "}
|
||||
{billingInfo.address.postalCode}
|
||||
</p>
|
||||
<p>{billingInfo.address.country}</p>
|
||||
</div>
|
||||
|
||||
{/* Address Confirmation for Internet Orders */}
|
||||
{isInternetOrder && !addressConfirmed && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm text-amber-700 font-medium">
|
||||
Verification Required
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmAddress}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors font-medium active:bg-green-800 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
>
|
||||
✓ Confirm Installation Address
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address Confirmed Status */}
|
||||
{addressConfirmed && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-green-700 font-medium">
|
||||
{isInternetOrder ? "Installation Address Confirmed" : "Address Confirmed"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">No address on file</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEdit}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add Address
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
@ -83,6 +83,9 @@ importers:
|
||||
"@nestjs/schedule":
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
|
||||
"@nestjs/swagger":
|
||||
specifier: ^11.2.3
|
||||
version: 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
|
||||
"@prisma/adapter-pg":
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
@ -118,7 +121,7 @@ importers:
|
||||
version: 4.5.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.1.0)(rxjs@7.8.2)
|
||||
nestjs-zod:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.13)
|
||||
version: 5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.13)
|
||||
p-queue:
|
||||
specifier: ^9.0.1
|
||||
version: 9.0.1
|
||||
@ -143,6 +146,9 @@ importers:
|
||||
ssh2-sftp-client:
|
||||
specifier: ^12.0.1
|
||||
version: 12.0.1
|
||||
swagger-ui-express:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(express@5.1.0)
|
||||
zod:
|
||||
specifier: "catalog:"
|
||||
version: 4.1.13
|
||||
@ -1376,10 +1382,10 @@ packages:
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
"@microsoft/tsdoc@0.15.1":
|
||||
"@microsoft/tsdoc@0.16.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==,
|
||||
integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==,
|
||||
}
|
||||
|
||||
"@mrleebo/prisma-ast@0.12.1":
|
||||
@ -1720,10 +1726,10 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.2"
|
||||
|
||||
"@nestjs/swagger@11.2.0":
|
||||
"@nestjs/swagger@11.2.3":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-5wolt8GmpNcrQv34tIPUtPoV1EeFbCetm40Ij3+M0FNNnf2RJ3FyWfuQvI8SBlcJyfaounYVTKzKHreFXsUyOg==,
|
||||
integrity: sha512-a0xFfjeqk69uHIUpP8u0ryn4cKuHdra2Ug96L858i0N200Hxho+n3j+TlQXyOF4EstLSGjTfxI1Xb2E1lUxeNg==,
|
||||
}
|
||||
peerDependencies:
|
||||
"@fastify/static": ^8.0.0
|
||||
@ -5828,13 +5834,6 @@ packages:
|
||||
}
|
||||
engines: { node: 20 || >=22 }
|
||||
|
||||
path-to-regexp@8.2.0:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==,
|
||||
}
|
||||
engines: { node: ">=16" }
|
||||
|
||||
path-to-regexp@8.3.0:
|
||||
resolution:
|
||||
{
|
||||
@ -6861,6 +6860,21 @@ packages:
|
||||
integrity: sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==,
|
||||
}
|
||||
|
||||
swagger-ui-dist@5.30.2:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==,
|
||||
}
|
||||
|
||||
swagger-ui-express@5.0.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==,
|
||||
}
|
||||
engines: { node: ">= v0.10.32" }
|
||||
peerDependencies:
|
||||
express: ">=4.0.0 || >=5.0.0-beta"
|
||||
|
||||
symbol-observable@4.0.0:
|
||||
resolution:
|
||||
{
|
||||
@ -8126,8 +8140,7 @@ snapshots:
|
||||
|
||||
"@lukeed/csprng@1.1.0": {}
|
||||
|
||||
"@microsoft/tsdoc@0.15.1":
|
||||
optional: true
|
||||
"@microsoft/tsdoc@0.16.0": {}
|
||||
|
||||
"@mrleebo/prisma-ast@0.12.1":
|
||||
dependencies:
|
||||
@ -8311,7 +8324,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.2
|
||||
optional: true
|
||||
|
||||
"@nestjs/platform-express@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)":
|
||||
dependencies:
|
||||
@ -8342,21 +8354,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
"@nestjs/swagger@11.2.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)":
|
||||
"@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)":
|
||||
dependencies:
|
||||
"@microsoft/tsdoc": 0.15.1
|
||||
"@microsoft/tsdoc": 0.16.0
|
||||
"@nestjs/common": 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
"@nestjs/core": 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
"@nestjs/mapped-types": 2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
|
||||
js-yaml: 4.1.1
|
||||
lodash: 4.17.21
|
||||
path-to-regexp: 8.2.0
|
||||
path-to-regexp: 8.3.0
|
||||
reflect-metadata: 0.2.2
|
||||
swagger-ui-dist: 5.21.0
|
||||
swagger-ui-dist: 5.30.2
|
||||
optionalDependencies:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.14.2
|
||||
optional: true
|
||||
|
||||
"@nestjs/testing@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9)":
|
||||
dependencies:
|
||||
@ -8535,8 +8546,7 @@ snapshots:
|
||||
|
||||
"@protobufjs/utf8@1.1.0": {}
|
||||
|
||||
"@scarf/scarf@1.4.0":
|
||||
optional: true
|
||||
"@scarf/scarf@1.4.0": {}
|
||||
|
||||
"@sendgrid/client@8.1.6":
|
||||
dependencies:
|
||||
@ -10698,14 +10708,14 @@ snapshots:
|
||||
pino-http: 11.0.0
|
||||
rxjs: 7.8.2
|
||||
|
||||
nestjs-zod@5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.13):
|
||||
nestjs-zod@5.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.1.13):
|
||||
dependencies:
|
||||
"@nestjs/common": 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
deepmerge: 4.3.1
|
||||
rxjs: 7.8.2
|
||||
zod: 4.1.13
|
||||
optionalDependencies:
|
||||
"@nestjs/swagger": 11.2.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
|
||||
"@nestjs/swagger": 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
|
||||
|
||||
next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
dependencies:
|
||||
@ -10863,9 +10873,6 @@ snapshots:
|
||||
lru-cache: 11.2.4
|
||||
minipass: 7.1.2
|
||||
|
||||
path-to-regexp@8.2.0:
|
||||
optional: true
|
||||
|
||||
path-to-regexp@8.3.0: {}
|
||||
|
||||
path-type@4.0.0: {}
|
||||
@ -11498,7 +11505,15 @@ snapshots:
|
||||
swagger-ui-dist@5.21.0:
|
||||
dependencies:
|
||||
"@scarf/scarf": 1.4.0
|
||||
optional: true
|
||||
|
||||
swagger-ui-dist@5.30.2:
|
||||
dependencies:
|
||||
"@scarf/scarf": 1.4.0
|
||||
|
||||
swagger-ui-express@5.0.1(express@5.1.0):
|
||||
dependencies:
|
||||
express: 5.1.0
|
||||
swagger-ui-dist: 5.21.0
|
||||
|
||||
symbol-observable@4.0.0: {}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user