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:
tema 2025-12-25 17:51:02 +09:00
parent 87766fb1d5
commit f775a62c64
9 changed files with 65 additions and 1591 deletions

View File

@ -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": {

View File

@ -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,
};

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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",
},

View File

@ -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&apos;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&apos;ll set up your services in our system</p>
<p> Payment will be processed using your card on file</p>
<p> You&apos;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&apos;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>
);
}

View File

@ -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
View File

@ -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: {}