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.internetPlanTier,
|
||||
product.vpnRegion,
|
||||
product.bundledAddon,
|
||||
product.isBundledAddon,
|
||||
];
|
||||
|
||||
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 { OrderOrchestrator } from "./services/order-orchestrator.service";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
@ -12,12 +23,15 @@ import {
|
||||
type SfOrderIdParam,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||
import { Observable } from "rxjs";
|
||||
import { OrderEventsService } from "./services/order-events.service";
|
||||
|
||||
@Controller("orders")
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class OrdersController {
|
||||
constructor(
|
||||
private orderOrchestrator: OrderOrchestrator,
|
||||
private readonly orderEvents: OrderEventsService,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -63,6 +77,12 @@ export class OrdersController {
|
||||
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
|
||||
// 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 { PaymentValidatorService } from "./services/payment-validator.service";
|
||||
import { CheckoutService } from "./services/checkout.service";
|
||||
import { OrderEventsService } from "./services/order-events.service";
|
||||
|
||||
// Clean modular fulfillment services
|
||||
import { OrderFulfillmentValidator } from "./services/order-fulfillment-validator.service";
|
||||
@ -40,6 +41,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module";
|
||||
providers: [
|
||||
// Shared services
|
||||
PaymentValidatorService,
|
||||
OrderEventsService,
|
||||
|
||||
// Order creation services (modular)
|
||||
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 { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { OrderEventsService } from "./order-events.service";
|
||||
import {
|
||||
type OrderDetails,
|
||||
type OrderFulfillmentValidationResult,
|
||||
@ -56,7 +57,8 @@ export class OrderFulfillmentOrchestrator {
|
||||
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
||||
private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService,
|
||||
private readonly simFulfillmentService: SimFulfillmentService,
|
||||
private readonly distributedTransactionService: DistributedTransactionService
|
||||
private readonly distributedTransactionService: DistributedTransactionService,
|
||||
private readonly orderEvents: OrderEventsService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -92,6 +94,7 @@ export class OrderFulfillmentOrchestrator {
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
try {
|
||||
// Step 1: Validation (no rollback needed)
|
||||
try {
|
||||
context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest(
|
||||
@ -101,6 +104,18 @@ export class OrderFulfillmentOrchestrator {
|
||||
|
||||
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) {
|
||||
@ -141,10 +156,19 @@ export class OrderFulfillmentOrchestrator {
|
||||
id: "sf_status_update",
|
||||
description: "Update Salesforce order status to Activating",
|
||||
execute: async () => {
|
||||
return await this.salesforceService.updateOrder({
|
||||
const result = await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
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 () => {
|
||||
await this.salesforceService.updateOrder({
|
||||
@ -291,12 +315,25 @@ export class OrderFulfillmentOrchestrator {
|
||||
id: "sf_success_update",
|
||||
description: "Update Salesforce with success",
|
||||
execute: async () => {
|
||||
return await this.salesforceService.updateOrder({
|
||||
const result = await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
Status: "Completed",
|
||||
Activation_Status__c: "Activated",
|
||||
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 () => {
|
||||
await this.salesforceService.updateOrder({
|
||||
@ -321,12 +358,15 @@ export class OrderFulfillmentOrchestrator {
|
||||
stepsExecuted: fulfillmentResult.stepsExecuted,
|
||||
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
||||
});
|
||||
throw new FulfillmentException(fulfillmentResult.error || "Fulfillment transaction failed", {
|
||||
throw new FulfillmentException(
|
||||
fulfillmentResult.error || "Fulfillment transaction failed",
|
||||
{
|
||||
sfOrderId,
|
||||
idempotencyKey,
|
||||
stepsExecuted: fulfillmentResult.stepsExecuted,
|
||||
stepsRolledBack: fulfillmentResult.stepsRolledBack,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Update context with results
|
||||
@ -340,6 +380,19 @@ export class OrderFulfillmentOrchestrator {
|
||||
});
|
||||
|
||||
return context;
|
||||
} catch (error) {
|
||||
await this.handleFulfillmentError(context, error as Error);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -109,6 +109,18 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
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 {
|
||||
user: null,
|
||||
session: {},
|
||||
|
||||
@ -13,8 +13,11 @@ import {
|
||||
calculateOrderTotals,
|
||||
deriveOrderStatusDescriptor,
|
||||
getServiceCategory,
|
||||
summarizePrimaryItem,
|
||||
} from "@/features/orders/utils/order-presenters";
|
||||
import {
|
||||
buildOrderDisplayItems,
|
||||
summarizeOrderDisplayItems,
|
||||
} from "@/features/orders/utils/order-display";
|
||||
import type { OrderSummary } from "@customer-portal/domain/orders";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
@ -35,17 +38,17 @@ const STATUS_PILL_VARIANT = {
|
||||
} as const;
|
||||
|
||||
const SERVICE_ICON_STYLES = {
|
||||
internet: "bg-blue-100 text-blue-600",
|
||||
sim: "bg-violet-100 text-violet-600",
|
||||
vpn: "bg-teal-100 text-teal-600",
|
||||
default: "bg-slate-100 text-slate-600",
|
||||
internet: "bg-blue-50 text-blue-600",
|
||||
sim: "bg-violet-50 text-violet-600",
|
||||
vpn: "bg-teal-50 text-teal-600",
|
||||
default: "bg-slate-50 text-slate-600",
|
||||
} as const;
|
||||
|
||||
const CARD_TONE_STYLES = {
|
||||
success: "border-green-200 bg-green-50/80",
|
||||
info: "border-blue-100 bg-white",
|
||||
warning: "border-amber-100 bg-white",
|
||||
neutral: "border-gray-200 bg-white",
|
||||
const STATUS_ACCENT_TONE_CLASSES = {
|
||||
success: "bg-green-400/70",
|
||||
info: "bg-blue-400/70",
|
||||
warning: "bg-amber-400/70",
|
||||
neutral: "bg-slate-200",
|
||||
} as const;
|
||||
|
||||
const renderServiceIcon = (orderType?: string): ReactNode => {
|
||||
@ -71,9 +74,17 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
||||
const serviceCategory = getServiceCategory(order.orderType);
|
||||
const iconStyles = SERVICE_ICON_STYLES[serviceCategory];
|
||||
const serviceIcon = renderServiceIcon(order.orderType);
|
||||
const serviceSummary = summarizePrimaryItem(
|
||||
order.itemsSummary ?? [],
|
||||
const displayItems = useMemo(
|
||||
() => buildOrderDisplayItems(order.itemsSummary),
|
||||
[order.itemsSummary]
|
||||
);
|
||||
const serviceSummary = useMemo(
|
||||
() =>
|
||||
summarizeOrderDisplayItems(
|
||||
displayItems,
|
||||
order.itemSummary || "Service package"
|
||||
),
|
||||
[displayItems, order.itemSummary]
|
||||
);
|
||||
const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount);
|
||||
const createdDate = useMemo(() => {
|
||||
@ -104,10 +115,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
||||
<article
|
||||
key={String(order.id)}
|
||||
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 &&
|
||||
"cursor-pointer hover:border-blue-300 focus-within:border-blue-400 focus-within:ring-2 focus-within:ring-blue-100",
|
||||
CARD_TONE_STYLES[statusDescriptor.tone],
|
||||
"cursor-pointer hover:border-blue-200 hover:shadow-md focus-within:border-blue-300 focus-within:ring-2 focus-within:ring-blue-100",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
@ -115,53 +125,83 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
||||
role={isInteractive ? "button" : undefined}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<span
|
||||
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={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}
|
||||
</div>
|
||||
<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">
|
||||
<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)}
|
||||
</span>
|
||||
<span className="hidden text-gray-300 sm:inline">•</span>
|
||||
<span>
|
||||
{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}
|
||||
</span>
|
||||
<span>{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}</span>
|
||||
</div>
|
||||
<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 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} />
|
||||
{showPricing && (
|
||||
<div className="text-right">
|
||||
{totals.monthlyTotal > 0 && (
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
<div className="text-sm text-gray-500">
|
||||
{showPricing ? (
|
||||
<div className="space-y-1">
|
||||
{totals.monthlyTotal > 0 ? (
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
¥{totals.monthlyTotal.toLocaleString()}
|
||||
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
|
||||
)}
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<p className="text-xs font-semibold text-orange-600">
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
¥{totals.oneTimeTotal.toLocaleString()} one-time
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-gray-500">Included in plan</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(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 ? (
|
||||
<span className="flex items-center gap-2 font-medium text-blue-600">
|
||||
View order
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
View details
|
||||
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@ -2,23 +2,25 @@
|
||||
|
||||
export function OrderCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div className="animate-pulse space-y-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-7 shadow-sm">
|
||||
<span className="pointer-events-none absolute inset-x-6 top-6 h-1 rounded-full bg-slate-200" aria-hidden />
|
||||
<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="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="h-5 w-3/4 rounded bg-gray-200" />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="h-3 w-28 rounded bg-gray-200" />
|
||||
<div className="h-3 w-24 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="h-3 w-5/6 rounded bg-gray-200" />
|
||||
<div className="h-6 w-3/4 rounded bg-slate-200" />
|
||||
<div className="h-3 w-5/6 rounded bg-slate-200" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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-6 w-24 rounded-full bg-gray-200" />
|
||||
</div>
|
||||
<div className="h-4 w-32 rounded bg-gray-200" />
|
||||
<div className="h-6 w-24 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
<div className="h-4 w-32 rounded bg-slate-200" />
|
||||
</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));
|
||||
}
|
||||
|
||||
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}", {
|
||||
params: { path: { sfOrderId: orderId } },
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!response.data) {
|
||||
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";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import {
|
||||
@ -21,13 +21,19 @@ import {
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { ordersService } from "@/features/orders/services/orders.service";
|
||||
import { useOrderUpdates, type OrderUpdateEventPayload } from "@/features/orders/hooks/useOrderUpdates";
|
||||
import {
|
||||
calculateOrderTotals,
|
||||
deriveOrderStatusDescriptor,
|
||||
getServiceCategory,
|
||||
normalizeBillingCycle,
|
||||
} 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";
|
||||
|
||||
const STATUS_PILL_VARIANT: Record<
|
||||
@ -53,98 +59,62 @@ const renderServiceIcon = (category: ReturnType<typeof getServiceCategory>, clas
|
||||
}
|
||||
};
|
||||
|
||||
type ItemPresentationType = "service" | "installation" | "addon" | "activation" | "other";
|
||||
|
||||
const ITEM_THEMES: Record<
|
||||
ItemPresentationType,
|
||||
const CATEGORY_CONFIG: Record<
|
||||
OrderDisplayItemCategory,
|
||||
{
|
||||
container: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
iconStyles: string;
|
||||
tagStyles: string;
|
||||
priceStyles: string;
|
||||
typeLabel: string;
|
||||
icon: typeof SparklesIcon;
|
||||
badgeClass: string;
|
||||
label: string;
|
||||
}
|
||||
> = {
|
||||
service: {
|
||||
container: "border-blue-100 bg-blue-50",
|
||||
icon: SparklesIcon,
|
||||
iconStyles: "bg-blue-100 text-blue-600",
|
||||
tagStyles: "bg-blue-100 text-blue-700",
|
||||
priceStyles: "text-blue-700",
|
||||
typeLabel: "Service",
|
||||
badgeClass: "bg-blue-50 text-blue-700",
|
||||
label: "Service",
|
||||
},
|
||||
installation: {
|
||||
container: "border-emerald-100 bg-emerald-50",
|
||||
icon: WrenchScrewdriverIcon,
|
||||
iconStyles: "bg-emerald-100 text-emerald-600",
|
||||
tagStyles: "bg-emerald-100 text-emerald-700",
|
||||
priceStyles: "text-emerald-700",
|
||||
typeLabel: "Installation",
|
||||
badgeClass: "bg-emerald-50 text-emerald-700",
|
||||
label: "Installation",
|
||||
},
|
||||
addon: {
|
||||
container: "border-indigo-100 bg-indigo-50",
|
||||
icon: PuzzlePieceIcon,
|
||||
iconStyles: "bg-indigo-100 text-indigo-600",
|
||||
tagStyles: "bg-indigo-100 text-indigo-700",
|
||||
priceStyles: "text-indigo-700",
|
||||
typeLabel: "Add-on",
|
||||
badgeClass: "bg-violet-50 text-violet-700",
|
||||
label: "Add-on",
|
||||
},
|
||||
activation: {
|
||||
container: "border-amber-100 bg-amber-50",
|
||||
icon: BoltIcon,
|
||||
iconStyles: "bg-amber-100 text-amber-600",
|
||||
tagStyles: "bg-amber-100 text-amber-700",
|
||||
priceStyles: "text-amber-700",
|
||||
typeLabel: "Activation",
|
||||
badgeClass: "bg-amber-50 text-amber-700",
|
||||
label: "Activation",
|
||||
},
|
||||
other: {
|
||||
container: "border-slate-200 bg-slate-50",
|
||||
icon: Squares2X2Icon,
|
||||
iconStyles: "bg-slate-200 text-slate-600",
|
||||
tagStyles: "bg-slate-200 text-slate-700",
|
||||
priceStyles: "text-slate-700",
|
||||
typeLabel: "Item",
|
||||
badgeClass: "bg-slate-100 text-slate-600",
|
||||
label: "Item",
|
||||
},
|
||||
};
|
||||
|
||||
interface PresentedItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ItemPresentationType;
|
||||
billingLabel: string;
|
||||
billingSuffix: string | null;
|
||||
quantityLabel: string | null;
|
||||
price: number;
|
||||
statusLabel?: string;
|
||||
sku?: string;
|
||||
}
|
||||
const STATUS_ACCENT_TONE_CLASSES = {
|
||||
success: "bg-green-400/80",
|
||||
info: "bg-blue-400/80",
|
||||
warning: "bg-amber-400/80",
|
||||
neutral: "bg-slate-200",
|
||||
} as const;
|
||||
|
||||
const determineItemType = (item: OrderItemSummary): ItemPresentationType => {
|
||||
const candidates = [item.itemClass, item.status, item.productName, item.name].map(
|
||||
value => value?.toLowerCase() ?? ""
|
||||
);
|
||||
const describeCharge = (charge: OrderDisplayItemCharge): string => {
|
||||
if (typeof charge.suffix === "string" && charge.suffix.trim().length > 0) {
|
||||
return charge.suffix.trim();
|
||||
}
|
||||
|
||||
if (candidates.some(value => value.includes("install"))) {
|
||||
return "installation";
|
||||
if (charge.kind === "one-time") {
|
||||
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) => {
|
||||
const normalized = normalizeBillingCycle(billingCycle ?? undefined);
|
||||
if (normalized === "monthly") return { label: "Monthly", suffix: "/ month" };
|
||||
if (normalized === "onetime") return { label: "One-time", suffix: null };
|
||||
return { label: "Billing", suffix: null };
|
||||
if (charge.kind === "monthly") {
|
||||
return "/ month";
|
||||
}
|
||||
|
||||
return charge.label.toLowerCase();
|
||||
};
|
||||
|
||||
const yenFormatter = new Intl.NumberFormat("ja-JP", {
|
||||
@ -159,6 +129,8 @@ export function OrderDetailContainer() {
|
||||
const [data, setData] = useState<OrderDetails | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isNewOrder = searchParams.get("status") === "success";
|
||||
const activeControllerRef = useRef<AbortController | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const statusDescriptor = data
|
||||
? deriveOrderStatusDescriptor({
|
||||
@ -174,28 +146,12 @@ export function OrderDetailContainer() {
|
||||
|
||||
const serviceCategory = getServiceCategory(data?.orderType);
|
||||
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[]>(() => {
|
||||
if (!data?.itemsSummary) return [];
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
const displayItems = useMemo<OrderDisplayItem[]>(() => {
|
||||
return buildOrderDisplayItems(data?.itemsSummary);
|
||||
}, [data?.itemsSummary]);
|
||||
|
||||
const totals = useMemo(
|
||||
@ -235,29 +191,65 @@ export function OrderDetailContainer() {
|
||||
}
|
||||
}, [data?.orderType, serviceCategory]);
|
||||
|
||||
const showFeeNotice = categorizedItems.some(
|
||||
item => item.type === "installation" || item.type === "activation"
|
||||
const showFeeNotice = displayItems.some(item =>
|
||||
item.categories.includes("installation") || item.categories.includes("activation")
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const fetchStatus = async () => {
|
||||
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);
|
||||
if (mounted) setData(order || null);
|
||||
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 (mounted) setError(e instanceof Error ? e.message : "Failed to load order");
|
||||
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(() => {
|
||||
isMountedRef.current = true;
|
||||
void fetchOrder();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (activeControllerRef.current) {
|
||||
activeControllerRef.current.abort();
|
||||
activeControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
void fetchStatus();
|
||||
const interval = setInterval(() => {
|
||||
void fetchStatus();
|
||||
}, 5000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [params.id]);
|
||||
}, [fetchOrder]);
|
||||
|
||||
const handleOrderUpdate = useCallback(
|
||||
(event: OrderUpdateEventPayload) => {
|
||||
if (!params.id || event.orderId !== params.id) {
|
||||
return;
|
||||
}
|
||||
void fetchOrder();
|
||||
},
|
||||
[fetchOrder, params.id]
|
||||
);
|
||||
|
||||
useOrderUpdates(params.id, handleOrderUpdate);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
@ -308,121 +300,135 @@ export function OrderDetailContainer() {
|
||||
|
||||
{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-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 h-14 w-14 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
|
||||
{serviceIcon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<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)}
|
||||
{placedDate ? ` • Placed ${placedDate}` : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{totals.monthlyTotal > 0 && (
|
||||
<div>
|
||||
<div className="text-right text-sm text-gray-500">
|
||||
<div className="space-y-1">
|
||||
{totals.monthlyTotal > 0 ? (
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{yenFormatter.format(totals.monthlyTotal)}
|
||||
<span className="ml-1 text-xs font-medium text-gray-500">/ month</span>
|
||||
</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">
|
||||
per month
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm font-semibold text-gray-500">No monthly charges</p>
|
||||
)}
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<p className="mt-2 text-sm font-semibold text-orange-600">
|
||||
<p className="text-xs font-medium text-gray-500">
|
||||
{yenFormatter.format(totals.oneTimeTotal)} one-time
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
Your Services & Products
|
||||
</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
{categorizedItems.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="mt-4 space-y-3">
|
||||
{displayItems.length === 0 ? (
|
||||
<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.
|
||||
</div>
|
||||
) : (
|
||||
categorizedItems.map(item => {
|
||||
const theme = ITEM_THEMES[item.type];
|
||||
const Icon = theme.icon;
|
||||
const isIncluded = item.price <= 0;
|
||||
displayItems.map(item => {
|
||||
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
|
||||
const Icon = categoryConfig.icon;
|
||||
const categories = Array.from(new Set(item.categories));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
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"
|
||||
>
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-11 w-11 items-center justify-center rounded-xl",
|
||||
theme.iconStyles
|
||||
)}
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<p className="text-base font-semibold text-gray-900">{item.name}</p>
|
||||
{item.sku && (
|
||||
<span className="text-xs font-medium text-gray-400">
|
||||
SKU {item.sku}
|
||||
{typeof item.quantity === "number" && item.quantity > 1 && (
|
||||
<span className="text-xs font-medium text-gray-500">×{item.quantity}</span>
|
||||
)}
|
||||
{item.status && (
|
||||
<span className="text-[11px] uppercase tracking-wide text-gray-400">
|
||||
{item.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-semibold text-gray-500">
|
||||
{item.billingLabel}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
{categories.map(category => {
|
||||
const badge = CATEGORY_CONFIG[category] ?? CATEGORY_CONFIG.other;
|
||||
return (
|
||||
<span
|
||||
key={`${item.id}-${category}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
theme.tagStyles
|
||||
badge.badgeClass
|
||||
)}
|
||||
>
|
||||
{theme.typeLabel}
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{item.isBundle && (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">
|
||||
Bundle
|
||||
</span>
|
||||
{item.quantityLabel && (
|
||||
<span className="text-gray-400">{item.quantityLabel}</span>
|
||||
)}
|
||||
{isIncluded && (
|
||||
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[11px] font-medium text-gray-500">
|
||||
{item.included && (
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
Included
|
||||
</span>
|
||||
)}
|
||||
{item.statusLabel && (
|
||||
<span className="text-[11px] font-medium text-gray-400">
|
||||
{item.statusLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 text-sm text-gray-600 sm:items-end">
|
||||
{item.charges.map((charge, index) => {
|
||||
const descriptor = describeCharge(charge);
|
||||
if (charge.amount > 0) {
|
||||
return (
|
||||
<div key={`${item.id}-charge-${index}`} className="flex items-baseline gap-1">
|
||||
<span className="text-base font-semibold text-gray-900">
|
||||
{yenFormatter.format(charge.amount)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-medium text-gray-500">{descriptor}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<p
|
||||
className={cn(
|
||||
"text-base font-semibold",
|
||||
isIncluded ? "text-gray-500" : theme.priceStyles
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.id}-charge-${index}`}
|
||||
className="flex items-baseline gap-1 text-sm font-medium text-gray-500"
|
||||
>
|
||||
{isIncluded ? "No additional cost" : yenFormatter.format(item.price)}
|
||||
</p>
|
||||
{!isIncluded && item.billingSuffix && (
|
||||
<p className="text-xs text-gray-500">{item.billingSuffix}</p>
|
||||
)}
|
||||
Included
|
||||
<span className="text-xs text-gray-400">{descriptor}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -432,15 +438,15 @@ export function OrderDetailContainer() {
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-amber-500" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">Additional fees may apply</p>
|
||||
<p className="text-xs leading-relaxed">
|
||||
Weekend installation (+¥3,000), express setup, or specialised configuration
|
||||
work can add extra costs. We'll always confirm with you before applying
|
||||
any additional charges.
|
||||
Weekend installation, express setup, or specialised configuration work can
|
||||
add extra costs. We'll always confirm with you before applying any
|
||||
additional charges.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -448,17 +454,17 @@ export function OrderDetailContainer() {
|
||||
)}
|
||||
|
||||
{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>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Status
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-gray-900">
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{statusDescriptor.description}
|
||||
</p>
|
||||
{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>
|
||||
{statusDescriptor.timeline}
|
||||
</p>
|
||||
@ -467,7 +473,7 @@ export function OrderDetailContainer() {
|
||||
<StatusPill label={statusDescriptor.label} variant={statusPillVariant} />
|
||||
</div>
|
||||
{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="mt-1 text-sm text-blue-800">{statusDescriptor.nextAction}</p>
|
||||
</div>
|
||||
@ -487,3 +493,4 @@ export function OrderDetailContainer() {
|
||||
}
|
||||
|
||||
export default OrderDetailContainer;
|
||||
|
||||
|
||||
@ -13,8 +13,53 @@ export * from "./response-helpers";
|
||||
|
||||
// Import createClient for internal use
|
||||
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
|
||||
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;
|
||||
internetPlanTier: keyof SalesforceProduct2WithPricebookEntries;
|
||||
vpnRegion: keyof SalesforceProduct2WithPricebookEntries;
|
||||
bundledAddon: keyof SalesforceProduct2WithPricebookEntries;
|
||||
isBundledAddon: keyof SalesforceProduct2WithPricebookEntries;
|
||||
};
|
||||
}
|
||||
|
||||
@ -111,6 +113,8 @@ export const defaultSalesforceOrderFieldMap: SalesforceOrderFieldMap = {
|
||||
internetOfferingType: "Internet_Offering_Type__c",
|
||||
internetPlanTier: "Internet_Plan_Tier__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,
|
||||
internetPlanTier: ensureString(product[productFields.internetPlanTier]) ?? undefined,
|
||||
vpnRegion: ensureString(product[productFields.vpnRegion]) ?? undefined,
|
||||
isBundledAddon: coerceBoolean(product[productFields.isBundledAddon]),
|
||||
bundledAddonId: ensureString(product[productFields.bundledAddon]) ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@ -76,12 +78,15 @@ export function transformSalesforceOrderItem(
|
||||
productName: details.product?.name,
|
||||
name: details.product?.name,
|
||||
sku: details.product?.sku,
|
||||
productId: details.product?.id,
|
||||
status: undefined,
|
||||
billingCycle: details.billingCycle,
|
||||
itemClass: details.product?.itemClass,
|
||||
quantity: details.quantity,
|
||||
unitPrice: details.unitPrice,
|
||||
totalPrice: details.totalPrice,
|
||||
isBundledAddon: details.product?.isBundledAddon,
|
||||
bundledAddonId: details.product?.bundledAddonId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -194,3 +199,10 @@ function resolveWhmcsProductId(value: unknown): string | 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(),
|
||||
name: z.string().optional(),
|
||||
sku: z.string().optional(),
|
||||
productId: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
billingCycle: z.string().optional(),
|
||||
itemClass: z.string().optional(),
|
||||
quantity: z.number().int().min(0).optional(),
|
||||
unitPrice: 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(),
|
||||
internetPlanTier: z.string().optional(),
|
||||
vpnRegion: z.string().optional(),
|
||||
isBundledAddon: z.boolean().optional(),
|
||||
bundledAddonId: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user