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/core": "^11.1.9",
|
||||||
"@nestjs/platform-express": "^11.1.9",
|
"@nestjs/platform-express": "^11.1.9",
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
|
"@nestjs/swagger": "^11.2.3",
|
||||||
"@prisma/adapter-pg": "^7.1.0",
|
"@prisma/adapter-pg": "^7.1.0",
|
||||||
"@prisma/client": "^7.1.0",
|
"@prisma/client": "^7.1.0",
|
||||||
"@sendgrid/mail": "^8.1.6",
|
"@sendgrid/mail": "^8.1.6",
|
||||||
@ -59,6 +60,7 @@
|
|||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"salesforce-pubsub-api-client": "^5.5.1",
|
"salesforce-pubsub-api-client": "^5.5.1",
|
||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
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 { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_SOURCE,
|
NOTIFICATION_SOURCE,
|
||||||
@ -163,7 +164,7 @@ export class NotificationService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications: notifications.map(n => this.mapToNotification(n)),
|
notifications: notifications.map((n: PrismaNotification) => this.mapToNotification(n)),
|
||||||
unreadCount,
|
unreadCount,
|
||||||
total,
|
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const workspaceRoot = path.resolve(__dirname, "..", "..");
|
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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
|
output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
|
||||||
@ -23,11 +27,24 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
env: {
|
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_NAME: process.env.NEXT_PUBLIC_APP_NAME,
|
||||||
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
|
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: {
|
compiler: {
|
||||||
removeConsole: process.env.NODE_ENV === "production",
|
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":
|
"@nestjs/schedule":
|
||||||
specifier: ^6.1.0
|
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)
|
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":
|
"@prisma/adapter-pg":
|
||||||
specifier: ^7.1.0
|
specifier: ^7.1.0
|
||||||
version: 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)
|
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:
|
nestjs-zod:
|
||||||
specifier: ^5.0.1
|
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:
|
p-queue:
|
||||||
specifier: ^9.0.1
|
specifier: ^9.0.1
|
||||||
version: 9.0.1
|
version: 9.0.1
|
||||||
@ -143,6 +146,9 @@ importers:
|
|||||||
ssh2-sftp-client:
|
ssh2-sftp-client:
|
||||||
specifier: ^12.0.1
|
specifier: ^12.0.1
|
||||||
version: 12.0.1
|
version: 12.0.1
|
||||||
|
swagger-ui-express:
|
||||||
|
specifier: ^5.0.1
|
||||||
|
version: 5.0.1(express@5.1.0)
|
||||||
zod:
|
zod:
|
||||||
specifier: "catalog:"
|
specifier: "catalog:"
|
||||||
version: 4.1.13
|
version: 4.1.13
|
||||||
@ -1376,10 +1382,10 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=8" }
|
engines: { node: ">=8" }
|
||||||
|
|
||||||
"@microsoft/tsdoc@0.15.1":
|
"@microsoft/tsdoc@0.16.0":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==,
|
integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==,
|
||||||
}
|
}
|
||||||
|
|
||||||
"@mrleebo/prisma-ast@0.12.1":
|
"@mrleebo/prisma-ast@0.12.1":
|
||||||
@ -1720,10 +1726,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: ">=4.8.2"
|
typescript: ">=4.8.2"
|
||||||
|
|
||||||
"@nestjs/swagger@11.2.0":
|
"@nestjs/swagger@11.2.3":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
integrity: sha512-5wolt8GmpNcrQv34tIPUtPoV1EeFbCetm40Ij3+M0FNNnf2RJ3FyWfuQvI8SBlcJyfaounYVTKzKHreFXsUyOg==,
|
integrity: sha512-a0xFfjeqk69uHIUpP8u0ryn4cKuHdra2Ug96L858i0N200Hxho+n3j+TlQXyOF4EstLSGjTfxI1Xb2E1lUxeNg==,
|
||||||
}
|
}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@fastify/static": ^8.0.0
|
"@fastify/static": ^8.0.0
|
||||||
@ -5828,13 +5834,6 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: 20 || >=22 }
|
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:
|
path-to-regexp@8.3.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -6861,6 +6860,21 @@ packages:
|
|||||||
integrity: sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==,
|
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:
|
symbol-observable@4.0.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -8126,8 +8140,7 @@ snapshots:
|
|||||||
|
|
||||||
"@lukeed/csprng@1.1.0": {}
|
"@lukeed/csprng@1.1.0": {}
|
||||||
|
|
||||||
"@microsoft/tsdoc@0.15.1":
|
"@microsoft/tsdoc@0.16.0": {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
"@mrleebo/prisma-ast@0.12.1":
|
"@mrleebo/prisma-ast@0.12.1":
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -8311,7 +8324,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
class-transformer: 0.5.1
|
class-transformer: 0.5.1
|
||||||
class-validator: 0.14.2
|
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)":
|
"@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:
|
dependencies:
|
||||||
@ -8342,21 +8354,20 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- chokidar
|
- 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:
|
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/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/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)
|
"@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
|
js-yaml: 4.1.1
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
path-to-regexp: 8.2.0
|
path-to-regexp: 8.3.0
|
||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
swagger-ui-dist: 5.21.0
|
swagger-ui-dist: 5.30.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
class-transformer: 0.5.1
|
class-transformer: 0.5.1
|
||||||
class-validator: 0.14.2
|
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)":
|
"@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:
|
dependencies:
|
||||||
@ -8535,8 +8546,7 @@ snapshots:
|
|||||||
|
|
||||||
"@protobufjs/utf8@1.1.0": {}
|
"@protobufjs/utf8@1.1.0": {}
|
||||||
|
|
||||||
"@scarf/scarf@1.4.0":
|
"@scarf/scarf@1.4.0": {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
"@sendgrid/client@8.1.6":
|
"@sendgrid/client@8.1.6":
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -10698,14 +10708,14 @@ snapshots:
|
|||||||
pino-http: 11.0.0
|
pino-http: 11.0.0
|
||||||
rxjs: 7.8.2
|
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:
|
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)
|
"@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
|
deepmerge: 4.3.1
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
zod: 4.1.13
|
zod: 4.1.13
|
||||||
optionalDependencies:
|
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):
|
next@16.0.10(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -10863,9 +10873,6 @@ snapshots:
|
|||||||
lru-cache: 11.2.4
|
lru-cache: 11.2.4
|
||||||
minipass: 7.1.2
|
minipass: 7.1.2
|
||||||
|
|
||||||
path-to-regexp@8.2.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
path-to-regexp@8.3.0: {}
|
path-to-regexp@8.3.0: {}
|
||||||
|
|
||||||
path-type@4.0.0: {}
|
path-type@4.0.0: {}
|
||||||
@ -11498,7 +11505,15 @@ snapshots:
|
|||||||
swagger-ui-dist@5.21.0:
|
swagger-ui-dist@5.21.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@scarf/scarf": 1.4.0
|
"@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: {}
|
symbol-observable@4.0.0: {}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user