Enhance order processing and event streaming capabilities
- Introduced OrderEventsService to manage order-related events and updates. - Added SSE endpoint in OrdersController for streaming order updates to clients. - Enhanced OrderFulfillmentOrchestrator to publish order status updates during fulfillment processes. - Updated order field mappings to include bundled add-on information for improved order detail handling. - Refactored OrderCard and OrderDetail components for better display of order information and status updates.
This commit is contained in:
parent
1dc8fbf36d
commit
5d011c87be
@ -97,6 +97,8 @@ export class OrderFieldMapService {
|
|||||||
product.internetOfferingType,
|
product.internetOfferingType,
|
||||||
product.internetPlanTier,
|
product.internetPlanTier,
|
||||||
product.vpnRegion,
|
product.vpnRegion,
|
||||||
|
product.bundledAddon,
|
||||||
|
product.isBundledAddon,
|
||||||
];
|
];
|
||||||
|
|
||||||
return unique([...base, ...additional]);
|
return unique([...base, ...additional]);
|
||||||
|
|||||||
@ -1,4 +1,15 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Request, UsePipes, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Request,
|
||||||
|
Sse,
|
||||||
|
UsePipes,
|
||||||
|
UseGuards,
|
||||||
|
type MessageEvent,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||||
import { OrderOrchestrator } from "./services/order-orchestrator.service";
|
import { OrderOrchestrator } from "./services/order-orchestrator.service";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||||
@ -12,12 +23,15 @@ import {
|
|||||||
type SfOrderIdParam,
|
type SfOrderIdParam,
|
||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { OrderEventsService } from "./services/order-events.service";
|
||||||
|
|
||||||
@Controller("orders")
|
@Controller("orders")
|
||||||
@UseGuards(ThrottlerGuard)
|
@UseGuards(ThrottlerGuard)
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
constructor(
|
constructor(
|
||||||
private orderOrchestrator: OrderOrchestrator,
|
private orderOrchestrator: OrderOrchestrator,
|
||||||
|
private readonly orderEvents: OrderEventsService,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -63,6 +77,12 @@ export class OrdersController {
|
|||||||
return this.orderOrchestrator.getOrder(params.sfOrderId);
|
return this.orderOrchestrator.getOrder(params.sfOrderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Sse(":sfOrderId/events")
|
||||||
|
@UsePipes(new ZodValidationPipe(sfOrderIdParamSchema))
|
||||||
|
streamOrderUpdates(@Param() params: SfOrderIdParam): Observable<MessageEvent> {
|
||||||
|
return this.orderEvents.subscribe(params.sfOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
// Note: Order provisioning has been moved to SalesforceProvisioningController
|
// Note: Order provisioning has been moved to SalesforceProvisioningController
|
||||||
// This controller now focuses only on customer-facing order operations
|
// This controller now focuses only on customer-facing order operations
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { OrderPricebookService } from "./services/order-pricebook.service";
|
|||||||
import { OrderOrchestrator } from "./services/order-orchestrator.service";
|
import { OrderOrchestrator } from "./services/order-orchestrator.service";
|
||||||
import { PaymentValidatorService } from "./services/payment-validator.service";
|
import { PaymentValidatorService } from "./services/payment-validator.service";
|
||||||
import { CheckoutService } from "./services/checkout.service";
|
import { CheckoutService } from "./services/checkout.service";
|
||||||
|
import { OrderEventsService } from "./services/order-events.service";
|
||||||
|
|
||||||
// Clean modular fulfillment services
|
// Clean modular fulfillment services
|
||||||
import { OrderFulfillmentValidator } from "./services/order-fulfillment-validator.service";
|
import { OrderFulfillmentValidator } from "./services/order-fulfillment-validator.service";
|
||||||
@ -40,6 +41,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module";
|
|||||||
providers: [
|
providers: [
|
||||||
// Shared services
|
// Shared services
|
||||||
PaymentValidatorService,
|
PaymentValidatorService,
|
||||||
|
OrderEventsService,
|
||||||
|
|
||||||
// Order creation services (modular)
|
// Order creation services (modular)
|
||||||
OrderValidator,
|
OrderValidator,
|
||||||
|
|||||||
107
apps/bff/src/modules/orders/services/order-events.service.ts
Normal file
107
apps/bff/src/modules/orders/services/order-events.service.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import type { MessageEvent } from "@nestjs/common";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
export interface OrderUpdateEvent {
|
||||||
|
orderId: string;
|
||||||
|
status?: string;
|
||||||
|
activationStatus?: string | null;
|
||||||
|
message?: string;
|
||||||
|
reason?: string;
|
||||||
|
stage?: "started" | "in_progress" | "completed" | "failed";
|
||||||
|
source?: string;
|
||||||
|
timestamp: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalObserver {
|
||||||
|
next: (event: MessageEvent) => void;
|
||||||
|
complete: () => void;
|
||||||
|
error: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrderEventsService {
|
||||||
|
private readonly logger = new Logger(OrderEventsService.name);
|
||||||
|
|
||||||
|
private readonly observers = new Map<string, Set<InternalObserver>>();
|
||||||
|
|
||||||
|
subscribe(orderId: string): Observable<MessageEvent> {
|
||||||
|
return new Observable<MessageEvent>(subscriber => {
|
||||||
|
const wrappedObserver: InternalObserver = {
|
||||||
|
next: value => subscriber.next(value),
|
||||||
|
complete: () => subscriber.complete(),
|
||||||
|
error: error => subscriber.error(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderObservers = this.observers.get(orderId) ?? new Set<InternalObserver>();
|
||||||
|
orderObservers.add(wrappedObserver);
|
||||||
|
this.observers.set(orderId, orderObservers);
|
||||||
|
|
||||||
|
this.logger.debug(`Order stream connected`, { orderId, listeners: orderObservers.size });
|
||||||
|
|
||||||
|
// Immediately notify client that stream is ready
|
||||||
|
wrappedObserver.next(
|
||||||
|
this.buildEvent("order.stream.ready", {
|
||||||
|
orderId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
wrappedObserver.next(
|
||||||
|
this.buildEvent("order.stream.heartbeat", {
|
||||||
|
orderId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
const currentObservers = this.observers.get(orderId);
|
||||||
|
if (currentObservers) {
|
||||||
|
currentObservers.delete(wrappedObserver);
|
||||||
|
if (currentObservers.size === 0) {
|
||||||
|
this.observers.delete(orderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.debug(`Order stream disconnected`, {
|
||||||
|
orderId,
|
||||||
|
listeners: currentObservers?.size ?? 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(orderId: string, update: OrderUpdateEvent): void {
|
||||||
|
const currentObservers = this.observers.get(orderId);
|
||||||
|
if (!currentObservers || currentObservers.size === 0) {
|
||||||
|
this.logger.debug("No active listeners for order update", { orderId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = this.buildEvent("order.update", update);
|
||||||
|
|
||||||
|
currentObservers.forEach(observer => {
|
||||||
|
try {
|
||||||
|
observer.next(event);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to notify order update listener", {
|
||||||
|
orderId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEvent(event: string, data: Record<string, unknown>): MessageEvent {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
event,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
} satisfies MessageEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -11,6 +11,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"
|
|||||||
import { SimFulfillmentService } from "./sim-fulfillment.service";
|
import { SimFulfillmentService } from "./sim-fulfillment.service";
|
||||||
import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service";
|
import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import { OrderEventsService } from "./order-events.service";
|
||||||
import {
|
import {
|
||||||
type OrderDetails,
|
type OrderDetails,
|
||||||
type OrderFulfillmentValidationResult,
|
type OrderFulfillmentValidationResult,
|
||||||
@ -56,7 +57,8 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
||||||
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService,
|
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService,
|
||||||
private readonly simFulfillmentService: SimFulfillmentService,
|
private readonly simFulfillmentService: SimFulfillmentService,
|
||||||
private readonly distributedTransactionService: DistributedTransactionService
|
private readonly distributedTransactionService: DistributedTransactionService,
|
||||||
|
private readonly orderEvents: OrderEventsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -92,59 +94,81 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 1: Validation (no rollback needed)
|
|
||||||
try {
|
try {
|
||||||
context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest(
|
// Step 1: Validation (no rollback needed)
|
||||||
sfOrderId,
|
try {
|
||||||
idempotencyKey
|
context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest(
|
||||||
);
|
|
||||||
|
|
||||||
if (context.validation.isAlreadyProvisioned) {
|
|
||||||
this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId });
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("Fulfillment validation failed", {
|
|
||||||
sfOrderId,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Get order details (no rollback needed)
|
|
||||||
try {
|
|
||||||
const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId);
|
|
||||||
if (!orderDetails) {
|
|
||||||
throw new OrderValidationException("Order details could not be retrieved.", {
|
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
idempotencyKey,
|
idempotencyKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.validation.isAlreadyProvisioned) {
|
||||||
|
this.logger.log("Order already provisioned, skipping fulfillment", { sfOrderId });
|
||||||
|
this.orderEvents.publish(sfOrderId, {
|
||||||
|
orderId: sfOrderId,
|
||||||
|
status: "Completed",
|
||||||
|
activationStatus: "Activated",
|
||||||
|
stage: "completed",
|
||||||
|
source: "fulfillment",
|
||||||
|
message: "Order already provisioned",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
whmcsOrderId: context.validation.whmcsOrderId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Fulfillment validation failed", {
|
||||||
|
sfOrderId,
|
||||||
|
error: getErrorMessage(error),
|
||||||
});
|
});
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
context.orderDetails = orderDetails;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("Failed to get order details", {
|
|
||||||
sfOrderId,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Execute the main fulfillment workflow as a distributed transaction
|
// Step 2: Get order details (no rollback needed)
|
||||||
let mappingResult: WhmcsOrderItemMappingResult | undefined;
|
try {
|
||||||
let whmcsCreateResult: { orderId: number } | undefined;
|
const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId);
|
||||||
let whmcsAcceptResult: WhmcsOrderResult | undefined;
|
if (!orderDetails) {
|
||||||
|
throw new OrderValidationException("Order details could not be retrieved.", {
|
||||||
|
sfOrderId,
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
context.orderDetails = orderDetails;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to get order details", {
|
||||||
|
sfOrderId,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const fulfillmentResult =
|
// Step 3: Execute the main fulfillment workflow as a distributed transaction
|
||||||
await this.distributedTransactionService.executeDistributedTransaction(
|
let mappingResult: WhmcsOrderItemMappingResult | undefined;
|
||||||
[
|
let whmcsCreateResult: { orderId: number } | undefined;
|
||||||
|
let whmcsAcceptResult: WhmcsOrderResult | undefined;
|
||||||
|
|
||||||
|
const fulfillmentResult =
|
||||||
|
await this.distributedTransactionService.executeDistributedTransaction(
|
||||||
|
[
|
||||||
{
|
{
|
||||||
id: "sf_status_update",
|
id: "sf_status_update",
|
||||||
description: "Update Salesforce order status to Activating",
|
description: "Update Salesforce order status to Activating",
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
return await this.salesforceService.updateOrder({
|
const result = await this.salesforceService.updateOrder({
|
||||||
Id: sfOrderId,
|
Id: sfOrderId,
|
||||||
Activation_Status__c: "Activating",
|
Activation_Status__c: "Activating",
|
||||||
});
|
});
|
||||||
|
this.orderEvents.publish(sfOrderId, {
|
||||||
|
orderId: sfOrderId,
|
||||||
|
status: "Processing",
|
||||||
|
activationStatus: "Activating",
|
||||||
|
stage: "in_progress",
|
||||||
|
source: "fulfillment",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
rollback: async () => {
|
rollback: async () => {
|
||||||
await this.salesforceService.updateOrder({
|
await this.salesforceService.updateOrder({
|
||||||
@ -291,12 +315,25 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
id: "sf_success_update",
|
id: "sf_success_update",
|
||||||
description: "Update Salesforce with success",
|
description: "Update Salesforce with success",
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
return await this.salesforceService.updateOrder({
|
const result = await this.salesforceService.updateOrder({
|
||||||
Id: sfOrderId,
|
Id: sfOrderId,
|
||||||
Status: "Completed",
|
Status: "Completed",
|
||||||
Activation_Status__c: "Activated",
|
Activation_Status__c: "Activated",
|
||||||
WHMCS_Order_ID__c: whmcsAcceptResult?.orderId?.toString(),
|
WHMCS_Order_ID__c: whmcsAcceptResult?.orderId?.toString(),
|
||||||
});
|
});
|
||||||
|
this.orderEvents.publish(sfOrderId, {
|
||||||
|
orderId: sfOrderId,
|
||||||
|
status: "Completed",
|
||||||
|
activationStatus: "Activated",
|
||||||
|
stage: "completed",
|
||||||
|
source: "fulfillment",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
payload: {
|
||||||
|
whmcsOrderId: whmcsAcceptResult?.orderId,
|
||||||
|
whmcsServiceIds: whmcsAcceptResult?.serviceIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
rollback: async () => {
|
rollback: async () => {
|
||||||
await this.salesforceService.updateOrder({
|
await this.salesforceService.updateOrder({
|
||||||
@ -312,34 +349,50 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
timeout: 300000, // 5 minutes
|
timeout: 300000, // 5 minutes
|
||||||
continueOnNonCriticalFailure: true,
|
continueOnNonCriticalFailure: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!fulfillmentResult.success) {
|
if (!fulfillmentResult.success) {
|
||||||
this.logger.error("Fulfillment transaction failed", {
|
this.logger.error("Fulfillment transaction failed", {
|
||||||
|
sfOrderId,
|
||||||
|
error: fulfillmentResult.error,
|
||||||
|
stepsExecuted: fulfillmentResult.stepsExecuted,
|
||||||
|
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
||||||
|
});
|
||||||
|
throw new FulfillmentException(
|
||||||
|
fulfillmentResult.error || "Fulfillment transaction failed",
|
||||||
|
{
|
||||||
|
sfOrderId,
|
||||||
|
idempotencyKey,
|
||||||
|
stepsExecuted: fulfillmentResult.stepsExecuted,
|
||||||
|
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update context with results
|
||||||
|
context.mappingResult = mappingResult;
|
||||||
|
context.whmcsResult = whmcsAcceptResult;
|
||||||
|
|
||||||
|
this.logger.log("Transactional fulfillment completed successfully", {
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
error: fulfillmentResult.error,
|
|
||||||
stepsExecuted: fulfillmentResult.stepsExecuted,
|
stepsExecuted: fulfillmentResult.stepsExecuted,
|
||||||
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
duration: fulfillmentResult.duration,
|
||||||
});
|
});
|
||||||
throw new FulfillmentException(fulfillmentResult.error || "Fulfillment transaction failed", {
|
|
||||||
sfOrderId,
|
return context;
|
||||||
idempotencyKey,
|
} catch (error) {
|
||||||
stepsExecuted: fulfillmentResult.stepsExecuted,
|
await this.handleFulfillmentError(context, error as Error);
|
||||||
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
this.orderEvents.publish(sfOrderId, {
|
||||||
|
orderId: sfOrderId,
|
||||||
|
status: "Pending Review",
|
||||||
|
activationStatus: "Failed",
|
||||||
|
stage: "failed",
|
||||||
|
source: "fulfillment",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
reason: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update context with results
|
|
||||||
context.mappingResult = mappingResult;
|
|
||||||
context.whmcsResult = whmcsAcceptResult;
|
|
||||||
|
|
||||||
this.logger.log("Transactional fulfillment completed successfully", {
|
|
||||||
sfOrderId,
|
|
||||||
stepsExecuted: fulfillmentResult.stepsExecuted,
|
|
||||||
duration: fulfillmentResult.duration,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -109,6 +109,18 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
await refreshPromise;
|
await refreshPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set up global listener for 401 errors from API client
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("auth:unauthorized", (event) => {
|
||||||
|
const customEvent = event as CustomEvent;
|
||||||
|
logger.warn(
|
||||||
|
{ url: customEvent.detail?.url, status: customEvent.detail?.status },
|
||||||
|
"401 Unauthorized detected - triggering logout"
|
||||||
|
);
|
||||||
|
void get().logout({ reason: "session-expired" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
session: {},
|
session: {},
|
||||||
|
|||||||
@ -13,8 +13,11 @@ import {
|
|||||||
calculateOrderTotals,
|
calculateOrderTotals,
|
||||||
deriveOrderStatusDescriptor,
|
deriveOrderStatusDescriptor,
|
||||||
getServiceCategory,
|
getServiceCategory,
|
||||||
summarizePrimaryItem,
|
|
||||||
} from "@/features/orders/utils/order-presenters";
|
} from "@/features/orders/utils/order-presenters";
|
||||||
|
import {
|
||||||
|
buildOrderDisplayItems,
|
||||||
|
summarizeOrderDisplayItems,
|
||||||
|
} from "@/features/orders/utils/order-display";
|
||||||
import type { OrderSummary } from "@customer-portal/domain/orders";
|
import type { OrderSummary } from "@customer-portal/domain/orders";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
@ -35,17 +38,17 @@ const STATUS_PILL_VARIANT = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const SERVICE_ICON_STYLES = {
|
const SERVICE_ICON_STYLES = {
|
||||||
internet: "bg-blue-100 text-blue-600",
|
internet: "bg-blue-50 text-blue-600",
|
||||||
sim: "bg-violet-100 text-violet-600",
|
sim: "bg-violet-50 text-violet-600",
|
||||||
vpn: "bg-teal-100 text-teal-600",
|
vpn: "bg-teal-50 text-teal-600",
|
||||||
default: "bg-slate-100 text-slate-600",
|
default: "bg-slate-50 text-slate-600",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const CARD_TONE_STYLES = {
|
const STATUS_ACCENT_TONE_CLASSES = {
|
||||||
success: "border-green-200 bg-green-50/80",
|
success: "bg-green-400/70",
|
||||||
info: "border-blue-100 bg-white",
|
info: "bg-blue-400/70",
|
||||||
warning: "border-amber-100 bg-white",
|
warning: "bg-amber-400/70",
|
||||||
neutral: "border-gray-200 bg-white",
|
neutral: "bg-slate-200",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const renderServiceIcon = (orderType?: string): ReactNode => {
|
const renderServiceIcon = (orderType?: string): ReactNode => {
|
||||||
@ -71,9 +74,17 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
|||||||
const serviceCategory = getServiceCategory(order.orderType);
|
const serviceCategory = getServiceCategory(order.orderType);
|
||||||
const iconStyles = SERVICE_ICON_STYLES[serviceCategory];
|
const iconStyles = SERVICE_ICON_STYLES[serviceCategory];
|
||||||
const serviceIcon = renderServiceIcon(order.orderType);
|
const serviceIcon = renderServiceIcon(order.orderType);
|
||||||
const serviceSummary = summarizePrimaryItem(
|
const displayItems = useMemo(
|
||||||
order.itemsSummary ?? [],
|
() => buildOrderDisplayItems(order.itemsSummary),
|
||||||
order.itemSummary || "Service package"
|
[order.itemsSummary]
|
||||||
|
);
|
||||||
|
const serviceSummary = useMemo(
|
||||||
|
() =>
|
||||||
|
summarizeOrderDisplayItems(
|
||||||
|
displayItems,
|
||||||
|
order.itemSummary || "Service package"
|
||||||
|
),
|
||||||
|
[displayItems, order.itemSummary]
|
||||||
);
|
);
|
||||||
const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount);
|
const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount);
|
||||||
const createdDate = useMemo(() => {
|
const createdDate = useMemo(() => {
|
||||||
@ -104,10 +115,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
|||||||
<article
|
<article
|
||||||
key={String(order.id)}
|
key={String(order.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative overflow-hidden rounded-2xl border bg-white p-5 shadow-sm transition-colors focus-visible:outline-none sm:p-6",
|
"group relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-7 shadow-sm transition-all focus-visible:outline-none",
|
||||||
isInteractive &&
|
isInteractive &&
|
||||||
"cursor-pointer hover:border-blue-300 focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100",
|
"cursor-pointer hover:border-blue-200 hover:shadow-md focus-within:border-blue-300 focus-within:ring-2 focus-within:ring-blue-100",
|
||||||
CARD_TONE_STYLES[statusDescriptor.tone],
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@ -115,53 +125,83 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
|||||||
role={isInteractive ? "button" : undefined}
|
role={isInteractive ? "button" : undefined}
|
||||||
tabIndex={isInteractive ? 0 : undefined}
|
tabIndex={isInteractive ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<span
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-x-4 top-4 h-1 rounded-full",
|
||||||
|
STATUS_ACCENT_TONE_CLASSES[statusDescriptor.tone]
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex min-w-0 flex-1 items-start gap-4">
|
<div className="flex min-w-0 flex-1 items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className={cn("flex h-12 w-12 items-center justify-center rounded-xl", iconStyles)}
|
className={cn(
|
||||||
|
"flex h-12 w-12 items-center justify-center rounded-xl border border-transparent",
|
||||||
|
iconStyles
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{serviceIcon}
|
{serviceIcon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">{serviceSummary}</h3>
|
<h3 className="text-xl font-semibold text-gray-900 line-clamp-2">{serviceSummary}</h3>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||||
<span className="font-medium uppercase tracking-wide text-gray-400">
|
<span className="font-medium uppercase tracking-[0.14em] text-gray-400">
|
||||||
Order #{order.orderNumber || String(order.id).slice(-8)}
|
Order #{order.orderNumber || String(order.id).slice(-8)}
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden text-gray-300 sm:inline">•</span>
|
<span className="hidden text-gray-300 sm:inline">•</span>
|
||||||
<span>
|
<span>{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}</span>
|
||||||
{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 line-clamp-2">{statusDescriptor.description}</p>
|
<p className="text-sm text-gray-600 line-clamp-2">{statusDescriptor.description}</p>
|
||||||
|
{displayItems.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1 text-xs text-gray-500">
|
||||||
|
{displayItems.slice(1, 4).map(item => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className="rounded-full bg-slate-100 px-2 py-0.5 font-medium text-slate-600"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{displayItems.length > 4 && (
|
||||||
|
<span className="text-gray-400">+{displayItems.length - 4} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-3 text-right">
|
||||||
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
|
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
|
||||||
{showPricing && (
|
<div className="text-sm text-gray-500">
|
||||||
<div className="text-right">
|
{showPricing ? (
|
||||||
{totals.monthlyTotal > 0 && (
|
<div className="space-y-1">
|
||||||
<p className="text-lg font-semibold text-gray-900">
|
{totals.monthlyTotal > 0 ? (
|
||||||
¥{totals.monthlyTotal.toLocaleString()}
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
|
¥{totals.monthlyTotal.toLocaleString()}
|
||||||
</p>
|
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
|
||||||
)}
|
</p>
|
||||||
{totals.oneTimeTotal > 0 && (
|
) : (
|
||||||
<p className="text-xs font-semibold text-orange-600">
|
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
|
||||||
¥{totals.oneTimeTotal.toLocaleString()} one-time
|
)}
|
||||||
</p>
|
{totals.oneTimeTotal > 0 && (
|
||||||
)}
|
<p className="text-xs font-medium text-gray-500">
|
||||||
</div>
|
¥{totals.oneTimeTotal.toLocaleString()} one-time
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-semibold text-gray-500">Included in plan</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(isInteractive || footer) && (
|
{(isInteractive || footer) && (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-blue-600">
|
||||||
{isInteractive ? (
|
{isInteractive ? (
|
||||||
<span className="flex items-center gap-2 font-medium text-blue-600">
|
<span className="flex items-center gap-2 font-medium">
|
||||||
View order
|
View details
|
||||||
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -2,23 +2,25 @@
|
|||||||
|
|
||||||
export function OrderCardSkeleton() {
|
export function OrderCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:p-6">
|
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-7 shadow-sm">
|
||||||
<div className="animate-pulse space-y-5">
|
<span className="pointer-events-none absolute inset-x-6 top-6 h-1 rounded-full bg-slate-200" aria-hidden />
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex flex-1 items-start gap-4">
|
<div className="flex flex-1 items-start gap-4">
|
||||||
<div className="h-12 w-12 rounded-xl bg-gray-100" />
|
<div className="h-12 w-12 rounded-xl bg-slate-100" />
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-3">
|
||||||
<div className="h-5 w-3/4 rounded bg-gray-200" />
|
<div className="h-6 w-3/4 rounded bg-slate-200" />
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="h-3 w-5/6 rounded bg-slate-200" />
|
||||||
<div className="h-3 w-28 rounded bg-gray-200" />
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="h-3 w-24 rounded bg-gray-200" />
|
<div className="h-4 w-24 rounded-full bg-slate-200" />
|
||||||
|
<div className="h-4 w-20 rounded-full bg-slate-200" />
|
||||||
|
<div className="h-4 w-16 rounded-full bg-slate-100" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-3 w-5/6 rounded bg-gray-200" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-6 w-24 rounded-full bg-gray-200" />
|
<div className="h-6 w-24 rounded-full bg-slate-200" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-32 rounded bg-gray-200" />
|
<div className="h-4 w-32 rounded bg-slate-200" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
95
apps/portal/src/features/orders/hooks/useOrderUpdates.ts
Normal file
95
apps/portal/src/features/orders/hooks/useOrderUpdates.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { resolveBaseUrl } from "@/lib/api";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
export interface OrderStreamEvent<T extends string = string, P = unknown> {
|
||||||
|
event: T;
|
||||||
|
data: P;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderUpdateEventPayload {
|
||||||
|
orderId: string;
|
||||||
|
status?: string;
|
||||||
|
activationStatus?: string | null;
|
||||||
|
message?: string;
|
||||||
|
reason?: string;
|
||||||
|
stage?: "started" | "in_progress" | "completed" | "failed";
|
||||||
|
source?: string;
|
||||||
|
timestamp: string;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderUpdateHandler = (event: OrderUpdateEventPayload) => void;
|
||||||
|
|
||||||
|
export function useOrderUpdates(orderId: string | undefined, onUpdate: OrderUpdateHandler) {
|
||||||
|
const handlerRef = useRef<OrderUpdateHandler>(onUpdate);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handlerRef.current = onUpdate;
|
||||||
|
}, [onUpdate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const baseUrl = resolveBaseUrl();
|
||||||
|
const url = new URL(`/api/orders/${orderId}/events`, baseUrl).toString();
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
logger.debug({ orderId, url }, "Connecting to order updates stream");
|
||||||
|
const es = new EventSource(url, { withCredentials: true });
|
||||||
|
eventSource = es;
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent<string>) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data) as OrderStreamEvent;
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.event === "order.update") {
|
||||||
|
const payload = parsed.data as OrderUpdateEventPayload;
|
||||||
|
handlerRef.current?.(payload);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ orderId, error }, "Failed to parse order update event");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (error: Event) => {
|
||||||
|
logger.warn({ orderId, error }, "Order updates stream disconnected");
|
||||||
|
es.close();
|
||||||
|
eventSource = null;
|
||||||
|
|
||||||
|
if (!isCancelled) {
|
||||||
|
reconnectTimeout = setTimeout(connect, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.addEventListener("message", handleMessage as EventListener);
|
||||||
|
es.onerror = handleError;
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [orderId]);
|
||||||
|
}
|
||||||
|
|
||||||
@ -27,9 +27,14 @@ async function getMyOrders(): Promise<OrderSummary[]> {
|
|||||||
return data.map(item => orderSummarySchema.parse(item));
|
return data.map(item => orderSummarySchema.parse(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOrderById(orderId: string): Promise<OrderDetails> {
|
type GetOrderByIdOptions = {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getOrderById(orderId: string, options: GetOrderByIdOptions = {}): Promise<OrderDetails> {
|
||||||
const response = await apiClient.GET("/api/orders/{sfOrderId}", {
|
const response = await apiClient.GET("/api/orders/{sfOrderId}", {
|
||||||
params: { path: { sfOrderId: orderId } },
|
params: { path: { sfOrderId: orderId } },
|
||||||
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
if (!response.data) {
|
if (!response.data) {
|
||||||
throw new Error("Order not found");
|
throw new Error("Order not found");
|
||||||
|
|||||||
234
apps/portal/src/features/orders/utils/order-display.ts
Normal file
234
apps/portal/src/features/orders/utils/order-display.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import type { OrderItemSummary } from "@customer-portal/domain/orders";
|
||||||
|
import { normalizeBillingCycle } from "@/features/orders/utils/order-presenters";
|
||||||
|
|
||||||
|
export type OrderDisplayItemCategory = "service" | "installation" | "addon" | "activation" | "other";
|
||||||
|
|
||||||
|
export type OrderDisplayItemChargeKind = "monthly" | "one-time" | "other";
|
||||||
|
|
||||||
|
export interface OrderDisplayItemCharge {
|
||||||
|
kind: OrderDisplayItemChargeKind;
|
||||||
|
amount: number;
|
||||||
|
label: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderDisplayItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity?: number;
|
||||||
|
status?: string;
|
||||||
|
primaryCategory: OrderDisplayItemCategory;
|
||||||
|
categories: OrderDisplayItemCategory[];
|
||||||
|
charges: OrderDisplayItemCharge[];
|
||||||
|
included: boolean;
|
||||||
|
sourceItems: OrderItemSummary[];
|
||||||
|
isBundle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderItemGroup {
|
||||||
|
indices: number[];
|
||||||
|
items: OrderItemSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: OrderDisplayItemCategory[] = [
|
||||||
|
"service",
|
||||||
|
"installation",
|
||||||
|
"addon",
|
||||||
|
"activation",
|
||||||
|
"other",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
|
||||||
|
monthly: 0,
|
||||||
|
"one-time": 1,
|
||||||
|
other: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONTHLY_SUFFIX = "/ month";
|
||||||
|
|
||||||
|
const normalizeItemClass = (itemClass?: string | null): string =>
|
||||||
|
(itemClass ?? "").toLowerCase();
|
||||||
|
|
||||||
|
const resolveCategory = (item: OrderItemSummary): OrderDisplayItemCategory => {
|
||||||
|
const normalizedClass = normalizeItemClass(item.itemClass);
|
||||||
|
if (normalizedClass.includes("service")) return "service";
|
||||||
|
if (normalizedClass.includes("installation")) return "installation";
|
||||||
|
if (normalizedClass.includes("activation")) return "activation";
|
||||||
|
if (normalizedClass.includes("add-on") || normalizedClass.includes("addon")) return "addon";
|
||||||
|
return "other";
|
||||||
|
};
|
||||||
|
|
||||||
|
const coerceNumber = (value: unknown): number => {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildOrderItemId = (group: OrderItemGroup, fallbackIndex: number): string => {
|
||||||
|
const identifiers = group.items
|
||||||
|
.map(item => item.productId || item.sku || item.name)
|
||||||
|
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
||||||
|
if (identifiers.length === 0) {
|
||||||
|
return `order-item-${fallbackIndex}`;
|
||||||
|
}
|
||||||
|
return identifiers.join("|");
|
||||||
|
};
|
||||||
|
|
||||||
|
const aggregateCharges = (group: OrderItemGroup): OrderDisplayItemCharge[] => {
|
||||||
|
const accumulator = new Map<string, OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }>();
|
||||||
|
|
||||||
|
for (const item of group.items) {
|
||||||
|
const amount = coerceNumber(item.totalPrice ?? item.unitPrice);
|
||||||
|
const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined);
|
||||||
|
|
||||||
|
let kind: OrderDisplayItemChargeKind = "other";
|
||||||
|
let key = "other";
|
||||||
|
let label = item.billingCycle?.trim() || "Billing";
|
||||||
|
let suffix: string | undefined;
|
||||||
|
|
||||||
|
if (normalizedCycle === "monthly") {
|
||||||
|
kind = "monthly";
|
||||||
|
key = "monthly";
|
||||||
|
label = "Monthly";
|
||||||
|
suffix = MONTHLY_SUFFIX;
|
||||||
|
} else if (normalizedCycle === "onetime" || normalizedCycle === "free") {
|
||||||
|
kind = "one-time";
|
||||||
|
key = "one-time";
|
||||||
|
label = "One-time";
|
||||||
|
} else if (typeof item.billingCycle === "string" && item.billingCycle.length > 0) {
|
||||||
|
key = `other:${item.billingCycle.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = accumulator.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.amount += amount;
|
||||||
|
} else {
|
||||||
|
accumulator.set(key, { kind, amount, label, suffix });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(accumulator.values())
|
||||||
|
.map(({ kind, amount, label, suffix }) => ({ kind, amount, label, suffix }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const orderDiff = CHARGE_ORDER[a.kind] - CHARGE_ORDER[b.kind];
|
||||||
|
if (orderDiff !== 0) return orderDiff;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGroupName = (group: OrderItemGroup): string => {
|
||||||
|
const monthlyItem = group.items.find(
|
||||||
|
item => normalizeBillingCycle(item.billingCycle ?? undefined) === "monthly"
|
||||||
|
);
|
||||||
|
const fallbackItem = monthlyItem ?? group.items[0];
|
||||||
|
return fallbackItem?.productName || fallbackItem?.name || "Service item";
|
||||||
|
};
|
||||||
|
|
||||||
|
const determinePrimaryCategory = (group: OrderItemGroup): OrderDisplayItemCategory => {
|
||||||
|
const categories = group.items.map(resolveCategory);
|
||||||
|
for (const preferred of CATEGORY_ORDER) {
|
||||||
|
if (categories.includes(preferred)) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "other";
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectCategories = (group: OrderItemGroup): OrderDisplayItemCategory[] => {
|
||||||
|
const unique = new Set<OrderDisplayItemCategory>();
|
||||||
|
group.items.forEach(item => unique.add(resolveCategory(item)));
|
||||||
|
return Array.from(unique);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIncludedGroup = (charges: OrderDisplayItemCharge[]): boolean =>
|
||||||
|
charges.every(charge => charge.amount <= 0);
|
||||||
|
|
||||||
|
const buildOrderItemGroup = (items: OrderItemSummary[]): OrderItemGroup[] => {
|
||||||
|
const groups: OrderItemGroup[] = [];
|
||||||
|
const usedIndices = new Set<number>();
|
||||||
|
const productIndex = new Map<string, number[]>();
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const key = item.productId || item.sku;
|
||||||
|
if (!key) return;
|
||||||
|
const existing = productIndex.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(index);
|
||||||
|
} else {
|
||||||
|
productIndex.set(key, [index]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
if (usedIndices.has(index)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.isBundledAddon && item.bundledAddonId) {
|
||||||
|
const partnerCandidates = productIndex.get(item.bundledAddonId) ?? [];
|
||||||
|
const partnerIndex = partnerCandidates.find(candidate => candidate !== index && !usedIndices.has(candidate));
|
||||||
|
if (typeof partnerIndex === "number") {
|
||||||
|
const partner = items[partnerIndex];
|
||||||
|
if (partner) {
|
||||||
|
const orderedIndices = partnerIndex < index ? [partnerIndex, index] : [index, partnerIndex];
|
||||||
|
const groupItems = orderedIndices.map(i => items[i]);
|
||||||
|
groups.push({ indices: orderedIndices, items: groupItems });
|
||||||
|
usedIndices.add(index);
|
||||||
|
usedIndices.add(partnerIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({ indices: [index], items: [item] });
|
||||||
|
usedIndices.add(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups.sort((a, b) => a.indices[0] - b.indices[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildOrderDisplayItems(
|
||||||
|
items: OrderItemSummary[] | null | undefined
|
||||||
|
): OrderDisplayItem[] {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = buildOrderItemGroup(items);
|
||||||
|
|
||||||
|
return groups.map((group, groupIndex) => {
|
||||||
|
const charges = aggregateCharges(group);
|
||||||
|
return {
|
||||||
|
id: buildOrderItemId(group, groupIndex),
|
||||||
|
name: buildGroupName(group),
|
||||||
|
quantity:
|
||||||
|
group.items.length === 1 ? group.items[0]?.quantity ?? undefined : undefined,
|
||||||
|
status: group.items.length === 1 ? group.items[0]?.status ?? undefined : undefined,
|
||||||
|
primaryCategory: determinePrimaryCategory(group),
|
||||||
|
categories: collectCategories(group),
|
||||||
|
charges,
|
||||||
|
included: isIncludedGroup(charges),
|
||||||
|
sourceItems: group.items,
|
||||||
|
isBundle: group.items.length > 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeOrderDisplayItems(
|
||||||
|
items: OrderDisplayItem[],
|
||||||
|
fallback: string
|
||||||
|
): string {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [primary, ...rest] = items;
|
||||||
|
if (rest.length === 0) {
|
||||||
|
return primary.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${primary.name} +${rest.length} more`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import {
|
import {
|
||||||
@ -21,13 +21,19 @@ import {
|
|||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { ordersService } from "@/features/orders/services/orders.service";
|
import { ordersService } from "@/features/orders/services/orders.service";
|
||||||
|
import { useOrderUpdates, type OrderUpdateEventPayload } from "@/features/orders/hooks/useOrderUpdates";
|
||||||
import {
|
import {
|
||||||
calculateOrderTotals,
|
calculateOrderTotals,
|
||||||
deriveOrderStatusDescriptor,
|
deriveOrderStatusDescriptor,
|
||||||
getServiceCategory,
|
getServiceCategory,
|
||||||
normalizeBillingCycle,
|
|
||||||
} from "@/features/orders/utils/order-presenters";
|
} from "@/features/orders/utils/order-presenters";
|
||||||
import type { OrderDetails, OrderItemSummary } from "@customer-portal/domain/orders";
|
import {
|
||||||
|
buildOrderDisplayItems,
|
||||||
|
type OrderDisplayItem,
|
||||||
|
type OrderDisplayItemCategory,
|
||||||
|
type OrderDisplayItemCharge,
|
||||||
|
} from "@/features/orders/utils/order-display";
|
||||||
|
import type { OrderDetails } from "@customer-portal/domain/orders";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
const STATUS_PILL_VARIANT: Record<
|
const STATUS_PILL_VARIANT: Record<
|
||||||
@ -53,98 +59,62 @@ const renderServiceIcon = (category: ReturnType<typeof getServiceCategory>, clas
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type ItemPresentationType = "service" | "installation" | "addon" | "activation" | "other";
|
const CATEGORY_CONFIG: Record<
|
||||||
|
OrderDisplayItemCategory,
|
||||||
const ITEM_THEMES: Record<
|
|
||||||
ItemPresentationType,
|
|
||||||
{
|
{
|
||||||
container: string;
|
icon: typeof SparklesIcon;
|
||||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
badgeClass: string;
|
||||||
iconStyles: string;
|
label: string;
|
||||||
tagStyles: string;
|
|
||||||
priceStyles: string;
|
|
||||||
typeLabel: string;
|
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
service: {
|
service: {
|
||||||
container: "border-blue-100 bg-blue-50",
|
|
||||||
icon: SparklesIcon,
|
icon: SparklesIcon,
|
||||||
iconStyles: "bg-blue-100 text-blue-600",
|
badgeClass: "bg-blue-50 text-blue-700",
|
||||||
tagStyles: "bg-blue-100 text-blue-700",
|
label: "Service",
|
||||||
priceStyles: "text-blue-700",
|
|
||||||
typeLabel: "Service",
|
|
||||||
},
|
},
|
||||||
installation: {
|
installation: {
|
||||||
container: "border-emerald-100 bg-emerald-50",
|
|
||||||
icon: WrenchScrewdriverIcon,
|
icon: WrenchScrewdriverIcon,
|
||||||
iconStyles: "bg-emerald-100 text-emerald-600",
|
badgeClass: "bg-emerald-50 text-emerald-700",
|
||||||
tagStyles: "bg-emerald-100 text-emerald-700",
|
label: "Installation",
|
||||||
priceStyles: "text-emerald-700",
|
|
||||||
typeLabel: "Installation",
|
|
||||||
},
|
},
|
||||||
addon: {
|
addon: {
|
||||||
container: "border-indigo-100 bg-indigo-50",
|
|
||||||
icon: PuzzlePieceIcon,
|
icon: PuzzlePieceIcon,
|
||||||
iconStyles: "bg-indigo-100 text-indigo-600",
|
badgeClass: "bg-violet-50 text-violet-700",
|
||||||
tagStyles: "bg-indigo-100 text-indigo-700",
|
label: "Add-on",
|
||||||
priceStyles: "text-indigo-700",
|
|
||||||
typeLabel: "Add-on",
|
|
||||||
},
|
},
|
||||||
activation: {
|
activation: {
|
||||||
container: "border-amber-100 bg-amber-50",
|
|
||||||
icon: BoltIcon,
|
icon: BoltIcon,
|
||||||
iconStyles: "bg-amber-100 text-amber-600",
|
badgeClass: "bg-amber-50 text-amber-700",
|
||||||
tagStyles: "bg-amber-100 text-amber-700",
|
label: "Activation",
|
||||||
priceStyles: "text-amber-700",
|
|
||||||
typeLabel: "Activation",
|
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
container: "border-slate-200 bg-slate-50",
|
|
||||||
icon: Squares2X2Icon,
|
icon: Squares2X2Icon,
|
||||||
iconStyles: "bg-slate-200 text-slate-600",
|
badgeClass: "bg-slate-100 text-slate-600",
|
||||||
tagStyles: "bg-slate-200 text-slate-700",
|
label: "Item",
|
||||||
priceStyles: "text-slate-700",
|
|
||||||
typeLabel: "Item",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PresentedItem {
|
const STATUS_ACCENT_TONE_CLASSES = {
|
||||||
id: string;
|
success: "bg-green-400/80",
|
||||||
name: string;
|
info: "bg-blue-400/80",
|
||||||
type: ItemPresentationType;
|
warning: "bg-amber-400/80",
|
||||||
billingLabel: string;
|
neutral: "bg-slate-200",
|
||||||
billingSuffix: string | null;
|
} as const;
|
||||||
quantityLabel: string | null;
|
|
||||||
price: number;
|
|
||||||
statusLabel?: string;
|
|
||||||
sku?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const determineItemType = (item: OrderItemSummary): ItemPresentationType => {
|
const describeCharge = (charge: OrderDisplayItemCharge): string => {
|
||||||
const candidates = [item.itemClass, item.status, item.productName, item.name].map(
|
if (typeof charge.suffix === "string" && charge.suffix.trim().length > 0) {
|
||||||
value => value?.toLowerCase() ?? ""
|
return charge.suffix.trim();
|
||||||
);
|
}
|
||||||
|
|
||||||
if (candidates.some(value => value.includes("install"))) {
|
if (charge.kind === "one-time") {
|
||||||
return "installation";
|
return "one-time";
|
||||||
}
|
}
|
||||||
if (candidates.some(value => value.includes("add-on") || value.includes("addon"))) {
|
|
||||||
return "addon";
|
|
||||||
}
|
|
||||||
if (candidates.some(value => value.includes("activation"))) {
|
|
||||||
return "activation";
|
|
||||||
}
|
|
||||||
if (candidates.some(value => value.includes("service") || value.includes("plan"))) {
|
|
||||||
return "service";
|
|
||||||
}
|
|
||||||
return "other";
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatBillingLabel = (billingCycle?: string | null) => {
|
if (charge.kind === "monthly") {
|
||||||
const normalized = normalizeBillingCycle(billingCycle ?? undefined);
|
return "/ month";
|
||||||
if (normalized === "monthly") return { label: "Monthly", suffix: "/ month" };
|
}
|
||||||
if (normalized === "onetime") return { label: "One-time", suffix: null };
|
|
||||||
return { label: "Billing", suffix: null };
|
return charge.label.toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const yenFormatter = new Intl.NumberFormat("ja-JP", {
|
const yenFormatter = new Intl.NumberFormat("ja-JP", {
|
||||||
@ -159,6 +129,8 @@ export function OrderDetailContainer() {
|
|||||||
const [data, setData] = useState<OrderDetails | null>(null);
|
const [data, setData] = useState<OrderDetails | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const isNewOrder = searchParams.get("status") === "success";
|
const isNewOrder = searchParams.get("status") === "success";
|
||||||
|
const activeControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
const statusDescriptor = data
|
const statusDescriptor = data
|
||||||
? deriveOrderStatusDescriptor({
|
? deriveOrderStatusDescriptor({
|
||||||
@ -174,28 +146,12 @@ export function OrderDetailContainer() {
|
|||||||
|
|
||||||
const serviceCategory = getServiceCategory(data?.orderType);
|
const serviceCategory = getServiceCategory(data?.orderType);
|
||||||
const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6");
|
const serviceIcon = renderServiceIcon(serviceCategory, "h-6 w-6");
|
||||||
|
const accentTone = statusDescriptor
|
||||||
|
? STATUS_ACCENT_TONE_CLASSES[statusDescriptor.tone]
|
||||||
|
: STATUS_ACCENT_TONE_CLASSES.neutral;
|
||||||
|
|
||||||
const categorizedItems = useMemo<PresentedItem[]>(() => {
|
const displayItems = useMemo<OrderDisplayItem[]>(() => {
|
||||||
if (!data?.itemsSummary) return [];
|
return buildOrderDisplayItems(data?.itemsSummary);
|
||||||
|
|
||||||
return data.itemsSummary.map((item, index) => {
|
|
||||||
const type = determineItemType(item);
|
|
||||||
const billing = formatBillingLabel(item.billingCycle);
|
|
||||||
const quantityLabel =
|
|
||||||
typeof item.quantity === "number" && item.quantity > 1 ? `×${item.quantity}` : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.sku || item.name || `item-${index}`,
|
|
||||||
name: item.productName || item.name || "Service item",
|
|
||||||
type,
|
|
||||||
billingLabel: billing.label,
|
|
||||||
billingSuffix: billing.suffix,
|
|
||||||
quantityLabel,
|
|
||||||
price: item.totalPrice ?? 0,
|
|
||||||
statusLabel: item.status || undefined,
|
|
||||||
sku: item.sku,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [data?.itemsSummary]);
|
}, [data?.itemsSummary]);
|
||||||
|
|
||||||
const totals = useMemo(
|
const totals = useMemo(
|
||||||
@ -235,29 +191,65 @@ export function OrderDetailContainer() {
|
|||||||
}
|
}
|
||||||
}, [data?.orderType, serviceCategory]);
|
}, [data?.orderType, serviceCategory]);
|
||||||
|
|
||||||
const showFeeNotice = categorizedItems.some(
|
const showFeeNotice = displayItems.some(item =>
|
||||||
item => item.type === "installation" || item.type === "activation"
|
item.categories.includes("installation") || item.categories.includes("activation")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fetchOrder = useCallback(async (): Promise<void> => {
|
||||||
|
if (!params.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeControllerRef.current) {
|
||||||
|
activeControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeControllerRef.current = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const order = await ordersService.getOrderById(params.id, { signal: controller.signal });
|
||||||
|
if (!isMountedRef.current || controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setData(order ?? null);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
if (!isMountedRef.current || controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load order");
|
||||||
|
} finally {
|
||||||
|
if (activeControllerRef.current === controller) {
|
||||||
|
activeControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
isMountedRef.current = true;
|
||||||
const fetchStatus = async () => {
|
void fetchOrder();
|
||||||
try {
|
|
||||||
const order = await ordersService.getOrderById(params.id);
|
return () => {
|
||||||
if (mounted) setData(order || null);
|
isMountedRef.current = false;
|
||||||
} catch (e) {
|
if (activeControllerRef.current) {
|
||||||
if (mounted) setError(e instanceof Error ? e.message : "Failed to load order");
|
activeControllerRef.current.abort();
|
||||||
|
activeControllerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void fetchStatus();
|
}, [fetchOrder]);
|
||||||
const interval = setInterval(() => {
|
|
||||||
void fetchStatus();
|
const handleOrderUpdate = useCallback(
|
||||||
}, 5000);
|
(event: OrderUpdateEventPayload) => {
|
||||||
return () => {
|
if (!params.id || event.orderId !== params.id) {
|
||||||
mounted = false;
|
return;
|
||||||
clearInterval(interval);
|
}
|
||||||
};
|
void fetchOrder();
|
||||||
}, [params.id]);
|
},
|
||||||
|
[fetchOrder, params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
useOrderUpdates(params.id, handleOrderUpdate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
@ -308,37 +300,44 @@ export function OrderDetailContainer() {
|
|||||||
|
|
||||||
{data ? (
|
{data ? (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-8 shadow-sm sm:px-8">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-x-6 top-6 h-1 rounded-full",
|
||||||
|
accentTone
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="flex flex-1 items-start gap-4">
|
<div className="flex flex-1 items-start gap-4">
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
|
||||||
{serviceIcon}
|
{serviceIcon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<h2 className="text-2xl font-semibold text-gray-900">{serviceLabel}</h2>
|
<h2 className="text-2xl font-semibold text-gray-900">{serviceLabel}</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Order #{data.orderNumber || String(data.id).slice(-8)}
|
Order #{data.orderNumber || String(data.id).slice(-8)}
|
||||||
{placedDate ? ` • Placed ${placedDate}` : null}
|
{placedDate ? ` • Placed ${placedDate}` : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right text-sm text-gray-500">
|
||||||
{totals.monthlyTotal > 0 && (
|
<div className="space-y-1">
|
||||||
<div>
|
{totals.monthlyTotal > 0 ? (
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
{yenFormatter.format(totals.monthlyTotal)}
|
{yenFormatter.format(totals.monthlyTotal)}
|
||||||
|
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">
|
) : (
|
||||||
per month
|
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
|
||||||
|
)}
|
||||||
|
{totals.oneTimeTotal > 0 && (
|
||||||
|
<p className="text-xs font-medium text-gray-500">
|
||||||
|
{yenFormatter.format(totals.oneTimeTotal)} one-time
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
{totals.oneTimeTotal > 0 && (
|
|
||||||
<p className="mt-2 text-sm font-semibold text-orange-600">
|
|
||||||
{yenFormatter.format(totals.oneTimeTotal)} one-time
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -346,83 +345,90 @@ export function OrderDetailContainer() {
|
|||||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||||
Your Services & Products
|
Your Services & Products
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{categorizedItems.length === 0 ? (
|
{displayItems.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-gray-200 bg-gray-50 px-4 py-6 text-center text-sm text-gray-500">
|
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-center text-sm text-gray-500">
|
||||||
No items found on this order.
|
No items found on this order.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
categorizedItems.map(item => {
|
displayItems.map(item => {
|
||||||
const theme = ITEM_THEMES[item.type];
|
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
|
||||||
const Icon = theme.icon;
|
const Icon = categoryConfig.icon;
|
||||||
const isIncluded = item.price <= 0;
|
const categories = Array.from(new Set(item.categories));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={cn(
|
className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 px-4 py-4 shadow-sm sm:flex-row sm:items-center sm:justify-between sm:gap-6"
|
||||||
"flex flex-col gap-3 rounded-2xl border px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6",
|
|
||||||
theme.container
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 items-start gap-3">
|
<div className="flex flex-1 items-start gap-3">
|
||||||
<div
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
|
||||||
className={cn(
|
|
||||||
"flex h-11 w-11 items-center justify-center rounded-xl",
|
|
||||||
theme.iconStyles
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<div className="flex flex-wrap items-baseline gap-2">
|
<div className="flex flex-wrap items-baseline gap-2">
|
||||||
<p className="text-base font-semibold text-gray-900">{item.name}</p>
|
<p className="text-base font-semibold text-gray-900">{item.name}</p>
|
||||||
{item.sku && (
|
{typeof item.quantity === "number" && item.quantity > 1 && (
|
||||||
<span className="text-xs font-medium text-gray-400">
|
<span className="text-xs font-medium text-gray-500">×{item.quantity}</span>
|
||||||
SKU {item.sku}
|
)}
|
||||||
|
{item.status && (
|
||||||
|
<span className="text-[11px] uppercase tracking-wide text-gray-400">
|
||||||
|
{item.status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||||
<span className="font-semibold text-gray-500">
|
{categories.map(category => {
|
||||||
{item.billingLabel}
|
const badge = CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.other;
|
||||||
</span>
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
key={`${item.id}-${category}`}
|
||||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
className={cn(
|
||||||
theme.tagStyles
|
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||||
)}
|
badge.badgeClass
|
||||||
>
|
)}
|
||||||
{theme.typeLabel}
|
>
|
||||||
</span>
|
{badge.label}
|
||||||
{item.quantityLabel && (
|
</span>
|
||||||
<span className="text-gray-400">{item.quantityLabel}</span>
|
);
|
||||||
)}
|
})}
|
||||||
{isIncluded && (
|
{item.isBundle && (
|
||||||
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium text-gray-500">
|
<span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">
|
||||||
Included
|
Bundle
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.statusLabel && (
|
{item.included && (
|
||||||
<span className="text-[11px] font-medium text-gray-400">
|
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
{item.statusLabel}
|
Included
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left sm:text-right">
|
<div className="flex flex-col items-start gap-1 text-sm text-gray-600 sm:items-end">
|
||||||
<p
|
{item.charges.map((charge, index) => {
|
||||||
className={cn(
|
const descriptor = describeCharge(charge);
|
||||||
"text-base font-semibold",
|
if (charge.amount > 0) {
|
||||||
isIncluded ? "text-gray-500" : theme.priceStyles
|
return (
|
||||||
)}
|
<div key={`${item.id}-charge-${index}`} className="flex items-baseline gap-1">
|
||||||
>
|
<span className="text-base font-semibold text-gray-900">
|
||||||
{isIncluded ? "No additional cost" : yenFormatter.format(item.price)}
|
{yenFormatter.format(charge.amount)}
|
||||||
</p>
|
</span>
|
||||||
{!isIncluded && item.billingSuffix && (
|
<span className="text-xs font-medium text-gray-500">{descriptor}</span>
|
||||||
<p className="text-xs text-gray-500">{item.billingSuffix}</p>
|
</div>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.id}-charge-${index}`}
|
||||||
|
className="flex items-baseline gap-1 text-sm font-medium text-gray-500"
|
||||||
|
>
|
||||||
|
Included
|
||||||
|
<span className="text-xs text-gray-400">{descriptor}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -432,15 +438,15 @@ export function OrderDetailContainer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showFeeNotice && (
|
{showFeeNotice && (
|
||||||
<div className="mt-2 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-amber-900">
|
<div className="rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-amber-900">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-500" />
|
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-500" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-semibold">Additional fees may apply</p>
|
<p className="text-sm font-semibold">Additional fees may apply</p>
|
||||||
<p className="text-xs leading-relaxed">
|
<p className="text-xs leading-relaxed">
|
||||||
Weekend installation (+¥3,000), express setup, or specialised configuration
|
Weekend installation, express setup, or specialised configuration work can
|
||||||
work can add extra costs. We'll always confirm with you before applying
|
add extra costs. We'll always confirm with you before applying any
|
||||||
any additional charges.
|
additional charges.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -448,17 +454,17 @@ export function OrderDetailContainer() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{statusDescriptor && (
|
{statusDescriptor && (
|
||||||
<div className="mt-3 rounded-2xl border border-gray-100 bg-gray-50 p-5">
|
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
Status
|
Status
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-semibold text-gray-900">
|
<p className="text-lg font-semibold text-gray-900">
|
||||||
{statusDescriptor.description}
|
{statusDescriptor.description}
|
||||||
</p>
|
</p>
|
||||||
{statusDescriptor.timeline && (
|
{statusDescriptor.timeline && (
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
<span className="font-medium text-gray-700">Timeline: </span>
|
<span className="font-medium text-gray-700">Timeline: </span>
|
||||||
{statusDescriptor.timeline}
|
{statusDescriptor.timeline}
|
||||||
</p>
|
</p>
|
||||||
@ -467,7 +473,7 @@ export function OrderDetailContainer() {
|
|||||||
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
||||||
</div>
|
</div>
|
||||||
{statusDescriptor.nextAction && (
|
{statusDescriptor.nextAction && (
|
||||||
<div className="mt-4 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3">
|
<div className="mt-4 rounded-2xl border border-blue-100 bg-white p-4">
|
||||||
<p className="text-sm font-semibold text-blue-900">Next steps</p>
|
<p className="text-sm font-semibold text-blue-900">Next steps</p>
|
||||||
<p className="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
|
<p className="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -487,3 +493,4 @@ export function OrderDetailContainer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default OrderDetailContainer;
|
export default OrderDetailContainer;
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,53 @@ export * from "./response-helpers";
|
|||||||
|
|
||||||
// Import createClient for internal use
|
// Import createClient for internal use
|
||||||
import { createClient } from "./runtime/client";
|
import { createClient } from "./runtime/client";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const apiClient = createClient();
|
/**
|
||||||
|
* Global error handler for API client
|
||||||
|
* Handles authentication errors and triggers logout when needed
|
||||||
|
*/
|
||||||
|
async function handleApiError(response: Response): Promise<void> {
|
||||||
|
// Don't import useAuthStore at module level to avoid circular dependencies
|
||||||
|
// We'll handle auth errors by dispatching a custom event that the auth system can listen to
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
logger.warn("Received 401 Unauthorized response - triggering logout");
|
||||||
|
|
||||||
|
// Dispatch a custom event that the auth system will listen to
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:unauthorized", {
|
||||||
|
detail: { url: response.url, status: response.status }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still throw the error so the calling code can handle it
|
||||||
|
let body: unknown;
|
||||||
|
let message = response.statusText || `Request failed with status ${response.status}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cloned = response.clone();
|
||||||
|
const contentType = cloned.headers.get("content-type");
|
||||||
|
if (contentType?.includes("application/json")) {
|
||||||
|
body = await cloned.json();
|
||||||
|
if (body && typeof body === "object" && "message" in body) {
|
||||||
|
const maybeMessage = (body as { message?: unknown }).message;
|
||||||
|
if (typeof maybeMessage === "string") {
|
||||||
|
message = maybeMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore body parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new (await import("./runtime/client")).ApiError(message, response, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = createClient({
|
||||||
|
handleError: handleApiError,
|
||||||
|
});
|
||||||
|
|
||||||
// Query keys for React Query - matching the expected structure
|
// Query keys for React Query - matching the expected structure
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
|
|||||||
158
docs/orders/ORDER-STATUS-UPDATES-STRATEGY.md
Normal file
158
docs/orders/ORDER-STATUS-UPDATES-STRATEGY.md
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
# Order Status Updates Strategy
|
||||||
|
|
||||||
|
## Current Problem
|
||||||
|
|
||||||
|
The frontend was polling order details every 5-15 seconds, causing:
|
||||||
|
- Unnecessary load on backend (Salesforce API calls, DB queries)
|
||||||
|
- High API costs
|
||||||
|
- Battery drain on mobile devices
|
||||||
|
- Network waste
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
**Polling is a workaround for lack of real-time push notifications.**
|
||||||
|
|
||||||
|
When the backend completes provisioning and updates Salesforce, the frontend has no way to know except by repeatedly asking "is it done yet?"
|
||||||
|
|
||||||
|
## The Right Solution
|
||||||
|
|
||||||
|
### Phase 1: Remove Aggressive Polling ✅
|
||||||
|
|
||||||
|
- Polling loop removed from `OrderDetail` (Nov 2025)
|
||||||
|
- Order details now fetched once on mount and whenever the SSE stream notifies of a change
|
||||||
|
- Manual refresh handled via SSE-triggered re-fetch; no background polling left in the page
|
||||||
|
|
||||||
|
### Phase 2: Server-Sent Events ✅ (Implemented Nov 2025)
|
||||||
|
|
||||||
|
**Architecture (now live):**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||||
|
│ Salesforce │ │ BFF │ │ Frontend │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ Platform │──Event─▶│ Provisioning│───SSE──│ OrderDetail │
|
||||||
|
│ Event │ │ Processor │ │ Component │
|
||||||
|
└─────────────┘ └─────────────┘ └──────────────┘
|
||||||
|
│
|
||||||
|
│ 1. Provisions WHMCS
|
||||||
|
│ 2. Updates Salesforce
|
||||||
|
│ 3. Publishes SSE event
|
||||||
|
▼
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key pieces:**
|
||||||
|
|
||||||
|
- `OrderEventsService` keeps per-order subscribers alive, sends heartbeats, and emits `order.update` events
|
||||||
|
- `GET /orders/:sfOrderId/events` exposes the SSE stream (guarded by the same JWT auth pipeline)
|
||||||
|
- `OrderFulfillmentOrchestrator` now publishes updates when Salesforce status flips to `Activating`, when provisioning succeeds, and when failures occur
|
||||||
|
- `useOrderUpdates` hook opens the SSE connection from the browser and triggers a re-fetch of order details when an update arrives
|
||||||
|
- `OrderDetail` now relies on SSE instead of timers (auto-updating as soon as the backend pushes a change)
|
||||||
|
|
||||||
|
```apps/bff/src/modules/orders/services/order-events.service.ts
|
||||||
|
@Injectable()
|
||||||
|
export class OrderEventsService {
|
||||||
|
private readonly observers = new Map<string, Set<InternalObserver>>();
|
||||||
|
|
||||||
|
subscribe(orderId: string): Observable<MessageEvent> {
|
||||||
|
return new Observable<MessageEvent>(subscriber => {
|
||||||
|
// ... connection bookkeeping + heartbeat ...
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(orderId: string, update: OrderUpdateEvent): void {
|
||||||
|
const event = this.buildEvent("order.update", update);
|
||||||
|
currentObservers.forEach(observer => observer.next(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```apps/portal/src/features/orders/views/OrderDetail.tsx
|
||||||
|
const fetchOrder = useCallback(async (): Promise<void> => {
|
||||||
|
if (!params.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... fetch with AbortController + state updates ...
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
const handleOrderUpdate = useCallback(
|
||||||
|
(event: OrderUpdateEventPayload) => {
|
||||||
|
if (!params.id || event.orderId !== params.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void fetchOrder();
|
||||||
|
},
|
||||||
|
[fetchOrder, params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
useOrderUpdates(params.id, handleOrderUpdate);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Alternative - WebSockets (More Complex)
|
||||||
|
|
||||||
|
WebSockets provide bidirectional communication but are more complex to implement and maintain:
|
||||||
|
|
||||||
|
- Requires WebSocket server setup (socket.io or ws)
|
||||||
|
- More complex authentication
|
||||||
|
- Requires load balancer configuration (sticky sessions)
|
||||||
|
- Better for real-time chat, less ideal for status updates
|
||||||
|
|
||||||
|
**SSE is simpler and sufficient for status updates.**
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
1. ✅ Remove aggressive polling (Nov 2025)
|
||||||
|
2. ✅ Ship SSE-based order updates (Nov 2025)
|
||||||
|
3. ⏭️ Optional: Consider WebSockets only if you need bidirectional messaging
|
||||||
|
|
||||||
|
## Benefits of SSE Approach
|
||||||
|
|
||||||
|
✅ **Zero polling** - No unnecessary requests
|
||||||
|
✅ **Instant updates** - Frontend knows immediately when status changes
|
||||||
|
✅ **Simple protocol** - SSE is built into browsers, no library needed
|
||||||
|
✅ **Automatic reconnection** - Browsers handle reconnection automatically
|
||||||
|
✅ **Lower costs** - Fewer Salesforce API calls
|
||||||
|
✅ **Better UX** - Users see status changes in real-time
|
||||||
|
✅ **Less backend load** - No repeated queries
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
### Week 1: Remove Polling
|
||||||
|
- [x] Remove aggressive 5-second polling
|
||||||
|
- [x] Document interim strategy
|
||||||
|
|
||||||
|
### Week 2: Implement SSE Infrastructure
|
||||||
|
- [x] Create `OrderEventsService` in BFF
|
||||||
|
- [x] Expose SSE endpoint `GET /orders/:sfOrderId/events`
|
||||||
|
- [x] Publish fulfillment lifecycle events (activating, completed, failed)
|
||||||
|
|
||||||
|
### Week 3: Frontend Integration
|
||||||
|
- [x] Create `useOrderUpdates` hook
|
||||||
|
- [x] Wire `OrderDetail` to SSE (no timers)
|
||||||
|
- [x] Auto-refetch details on push updates
|
||||||
|
|
||||||
|
### Week 4: Post-launch Monitoring
|
||||||
|
- [ ] Add observability for SSE connection counts
|
||||||
|
- [ ] Track client error rates and reconnection attempts
|
||||||
|
- [ ] Review UX analytics after rollout
|
||||||
|
|
||||||
|
## Testing Considerations
|
||||||
|
|
||||||
|
- Test SSE connection drops and automatic reconnection
|
||||||
|
- Test multiple tabs subscribing to same order
|
||||||
|
- Test SSE with load balancer (ensure sticky sessions or shared pub/sub)
|
||||||
|
- Test browser compatibility (SSE supported in all modern browsers)
|
||||||
|
- Monitor SSE connection count and memory usage
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [MDN: Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
|
||||||
|
- [NestJS SSE Documentation](https://docs.nestjs.com/techniques/server-sent-events)
|
||||||
|
- Platform Events (Salesforce → BFF): Already implemented ✅
|
||||||
|
- SSE (BFF → Frontend): Live in production build
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: Phase 1 & 2 Complete, Phase 3 (monitoring) in progress
|
||||||
|
**Last Updated**: November 2025
|
||||||
|
**Owner**: Engineering Team
|
||||||
|
|
||||||
@ -54,6 +54,8 @@ export interface SalesforceOrderFieldMap {
|
|||||||
internetOfferingType: keyof SalesforceProduct2WithPricebookEntries;
|
internetOfferingType: keyof SalesforceProduct2WithPricebookEntries;
|
||||||
internetPlanTier: keyof SalesforceProduct2WithPricebookEntries;
|
internetPlanTier: keyof SalesforceProduct2WithPricebookEntries;
|
||||||
vpnRegion: keyof SalesforceProduct2WithPricebookEntries;
|
vpnRegion: keyof SalesforceProduct2WithPricebookEntries;
|
||||||
|
bundledAddon: keyof SalesforceProduct2WithPricebookEntries;
|
||||||
|
isBundledAddon: keyof SalesforceProduct2WithPricebookEntries;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,6 +113,8 @@ export const defaultSalesforceOrderFieldMap: SalesforceOrderFieldMap = {
|
|||||||
internetOfferingType: "Internet_Offering_Type__c",
|
internetOfferingType: "Internet_Offering_Type__c",
|
||||||
internetPlanTier: "Internet_Plan_Tier__c",
|
internetPlanTier: "Internet_Plan_Tier__c",
|
||||||
vpnRegion: "VPN_Region__c",
|
vpnRegion: "VPN_Region__c",
|
||||||
|
bundledAddon: "Bundled_Addon__c",
|
||||||
|
isBundledAddon: "Is_Bundled_Addon__c",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -66,6 +66,8 @@ export function transformSalesforceOrderItem(
|
|||||||
ensureString(product[productFields.internetOfferingType]) ?? undefined,
|
ensureString(product[productFields.internetOfferingType]) ?? undefined,
|
||||||
internetPlanTier: ensureString(product[productFields.internetPlanTier]) ?? undefined,
|
internetPlanTier: ensureString(product[productFields.internetPlanTier]) ?? undefined,
|
||||||
vpnRegion: ensureString(product[productFields.vpnRegion]) ?? undefined,
|
vpnRegion: ensureString(product[productFields.vpnRegion]) ?? undefined,
|
||||||
|
isBundledAddon: coerceBoolean(product[productFields.isBundledAddon]),
|
||||||
|
bundledAddonId: ensureString(product[productFields.bundledAddon]) ?? undefined,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
@ -76,12 +78,15 @@ export function transformSalesforceOrderItem(
|
|||||||
productName: details.product?.name,
|
productName: details.product?.name,
|
||||||
name: details.product?.name,
|
name: details.product?.name,
|
||||||
sku: details.product?.sku,
|
sku: details.product?.sku,
|
||||||
|
productId: details.product?.id,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
billingCycle: details.billingCycle,
|
billingCycle: details.billingCycle,
|
||||||
itemClass: details.product?.itemClass,
|
itemClass: details.product?.itemClass,
|
||||||
quantity: details.quantity,
|
quantity: details.quantity,
|
||||||
unitPrice: details.unitPrice,
|
unitPrice: details.unitPrice,
|
||||||
totalPrice: details.totalPrice,
|
totalPrice: details.totalPrice,
|
||||||
|
isBundledAddon: details.product?.isBundledAddon,
|
||||||
|
bundledAddonId: details.product?.bundledAddonId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -194,3 +199,10 @@ function resolveWhmcsProductId(value: unknown): string | undefined {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coerceBoolean(value: unknown): boolean | undefined {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@ -26,12 +26,15 @@ export const orderItemSummarySchema = z.object({
|
|||||||
productName: z.string().optional(),
|
productName: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
sku: z.string().optional(),
|
sku: z.string().optional(),
|
||||||
|
productId: z.string().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
billingCycle: z.string().optional(),
|
billingCycle: z.string().optional(),
|
||||||
itemClass: z.string().optional(),
|
itemClass: z.string().optional(),
|
||||||
quantity: z.number().int().min(0).optional(),
|
quantity: z.number().int().min(0).optional(),
|
||||||
unitPrice: z.number().optional(),
|
unitPrice: z.number().optional(),
|
||||||
totalPrice: z.number().optional(),
|
totalPrice: z.number().optional(),
|
||||||
|
isBundledAddon: z.boolean().optional(),
|
||||||
|
bundledAddonId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -54,6 +57,8 @@ export const orderItemDetailsSchema = z.object({
|
|||||||
internetOfferingType: z.string().optional(),
|
internetOfferingType: z.string().optional(),
|
||||||
internetPlanTier: z.string().optional(),
|
internetPlanTier: z.string().optional(),
|
||||||
vpnRegion: z.string().optional(),
|
vpnRegion: z.string().optional(),
|
||||||
|
isBundledAddon: z.boolean().optional(),
|
||||||
|
bundledAddonId: z.string().optional(),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user