Refactor order and subscription services to enhance validation and type safety. Updated order validation to use new SKU validation schema, improved SIM reissue profile handling by integrating a structured request object, and refined subscription fetching logic to utilize updated schemas. Additionally, enhanced error handling and logging for better traceability in service operations.

This commit is contained in:
barsa 2025-10-21 11:44:06 +09:00
parent 7ffd2d562f
commit 939922a40e
31 changed files with 868 additions and 586 deletions

View File

@ -6,7 +6,7 @@ import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/conn
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { import {
createOrderRequestSchema, createOrderRequestSchema,
orderBusinessValidationSchema, orderWithSkuValidationSchema,
type CreateOrderRequest, type CreateOrderRequest,
type OrderBusinessValidation, type OrderBusinessValidation,
} from "@customer-portal/domain/orders"; } from "@customer-portal/domain/orders";
@ -176,10 +176,11 @@ export class OrderValidator {
// 1b. Business validation (ensures userId-specific constraints) // 1b. Business validation (ensures userId-specific constraints)
let businessValidatedBody: OrderBusinessValidation; let businessValidatedBody: OrderBusinessValidation;
try { try {
businessValidatedBody = orderBusinessValidationSchema.parse({ const skuValidatedBody = orderWithSkuValidationSchema.parse({
...validatedBody, ...validatedBody,
userId, userId,
}); });
businessValidatedBody = skuValidatedBody;
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
const issues = error.issues.map(issue => { const issues = error.issues.map(issue => {

View File

@ -8,6 +8,7 @@ import type {
SimCancelRequest, SimCancelRequest,
SimTopUpHistoryRequest, SimTopUpHistoryRequest,
SimFeaturesUpdateRequest, SimFeaturesUpdateRequest,
SimReissueRequest,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface"; import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
@ -108,8 +109,12 @@ export class SimManagementService {
/** /**
* Reissue eSIM profile * Reissue eSIM profile
*/ */
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> { async reissueEsimProfile(
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid); userId: string,
subscriptionId: number,
request: SimReissueRequest
): Promise<void> {
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request);
} }
/** /**

View File

@ -4,6 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
import { SimValidationService } from "./sim-validation.service"; import { SimValidationService } from "./sim-validation.service";
import { SimNotificationService } from "./sim-notification.service"; import { SimNotificationService } from "./sim-notification.service";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimReissueRequest } from "@customer-portal/domain/sim";
@Injectable() @Injectable()
export class EsimManagementService { export class EsimManagementService {
@ -17,7 +18,11 @@ export class EsimManagementService {
/** /**
* Reissue eSIM profile * Reissue eSIM profile
*/ */
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> { async reissueEsimProfile(
userId: string,
subscriptionId: number,
request: SimReissueRequest
): Promise<void> {
try { try {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
@ -27,10 +32,9 @@ export class EsimManagementService {
throw new BadRequestException("This operation is only available for eSIM subscriptions"); throw new BadRequestException("This operation is only available for eSIM subscriptions");
} }
const newEid = request.newEid;
if (newEid) { if (newEid) {
if (!/^\d{32}$/.test(newEid)) {
throw new BadRequestException("Invalid EID format. Expected 32 digits.");
}
await this.freebitService.reissueEsimProfileEnhanced(account, newEid, { await this.freebitService.reissueEsimProfileEnhanced(account, newEid, {
oldEid: simDetails.eid, oldEid: simDetails.eid,
planCode: simDetails.planCode, planCode: simDetails.planCode,
@ -60,12 +64,12 @@ export class EsimManagementService {
error: sanitizedError, error: sanitizedError,
userId, userId,
subscriptionId, subscriptionId,
newEid: newEid || undefined, newEid: request.newEid || undefined,
}); });
await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", { await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", {
userId, userId,
subscriptionId, subscriptionId,
newEid: newEid || undefined, newEid: request.newEid || undefined,
error: sanitizedError, error: sanitizedError,
}); });
throw error; throw error;

View File

@ -15,6 +15,7 @@ import type {
SimCancelRequest, SimCancelRequest,
SimTopUpHistoryRequest, SimTopUpHistoryRequest,
SimFeaturesUpdateRequest, SimFeaturesUpdateRequest,
SimReissueRequest,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
@Injectable() @Injectable()
@ -98,8 +99,12 @@ export class SimOrchestratorService {
/** /**
* Reissue eSIM profile * Reissue eSIM profile
*/ */
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> { async reissueEsimProfile(
return this.esimManagement.reissueEsimProfile(userId, subscriptionId, newEid); userId: string,
subscriptionId: number,
request: SimReissueRequest
): Promise<void> {
return this.esimManagement.reissueEsimProfile(userId, subscriptionId, request);
} }
/** /**

View File

@ -23,6 +23,9 @@ export class SimPlanService {
subscriptionId: number, subscriptionId: number,
request: SimPlanChangeRequest request: SimPlanChangeRequest
): Promise<{ ipv4?: string; ipv6?: string }> { ): Promise<{ ipv4?: string; ipv6?: string }> {
const assignGlobalIp = request.assignGlobalIp ?? false;
let scheduledAt: string | undefined;
try { try {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
@ -31,27 +34,35 @@ export class SimPlanService {
throw new BadRequestException("Invalid plan code"); throw new BadRequestException("Invalid plan code");
} }
// Automatically set to 1st of next month scheduledAt = request.scheduledAt;
const nextMonth = new Date(); if (scheduledAt) {
nextMonth.setMonth(nextMonth.getMonth() + 1); if (!/^\d{8}$/.test(scheduledAt)) {
nextMonth.setDate(1); // Set to 1st of the month throw new BadRequestException("scheduledAt must be in YYYYMMDD format");
}
} else {
// Default to the 1st of the next month if no schedule provided.
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
const year = nextMonth.getFullYear();
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
const day = String(nextMonth.getDate()).padStart(2, "0");
scheduledAt = `${year}${month}${day}`;
}
// Format as YYYYMMDD for Freebit API this.logger.log("Submitting SIM plan change request", {
const year = nextMonth.getFullYear();
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
const day = String(nextMonth.getDate()).padStart(2, "0");
const scheduledAt = `${year}${month}${day}`;
this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, {
userId, userId,
subscriptionId, subscriptionId,
account, account,
newPlanCode: request.newPlanCode, newPlanCode: request.newPlanCode,
scheduledAt,
assignGlobalIp,
scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default",
}); });
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, { const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
assignGlobalIp: false, // Default to no global IP assignGlobalIp,
scheduledAt: scheduledAt, scheduledAt: scheduledAt!,
}); });
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
@ -59,8 +70,8 @@ export class SimPlanService {
subscriptionId, subscriptionId,
account, account,
newPlanCode: request.newPlanCode, newPlanCode: request.newPlanCode,
scheduledAt: scheduledAt, scheduledAt,
assignGlobalIp: false, assignGlobalIp,
}); });
await this.simNotification.notifySimAction("Change Plan", "SUCCESS", { await this.simNotification.notifySimAction("Change Plan", "SUCCESS", {
@ -69,6 +80,7 @@ export class SimPlanService {
account, account,
newPlanCode: request.newPlanCode, newPlanCode: request.newPlanCode,
scheduledAt, scheduledAt,
assignGlobalIp,
}); });
return result; return result;
@ -79,11 +91,15 @@ export class SimPlanService {
userId, userId,
subscriptionId, subscriptionId,
newPlanCode: request.newPlanCode, newPlanCode: request.newPlanCode,
assignGlobalIp,
scheduledAt,
}); });
await this.simNotification.notifySimAction("Change Plan", "ERROR", { await this.simNotification.notifySimAction("Change Plan", "ERROR", {
userId, userId,
subscriptionId, subscriptionId,
newPlanCode: request.newPlanCode, newPlanCode: request.newPlanCode,
assignGlobalIp,
scheduledAt,
error: sanitizedError, error: sanitizedError,
}); });
throw error; throw error;

View File

@ -32,10 +32,12 @@ import {
simChangePlanRequestSchema, simChangePlanRequestSchema,
simCancelRequestSchema, simCancelRequestSchema,
simFeaturesRequestSchema, simFeaturesRequestSchema,
simReissueRequestSchema,
type SimTopupRequest, type SimTopupRequest,
type SimChangePlanRequest, type SimChangePlanRequest,
type SimCancelRequest, type SimCancelRequest,
type SimFeaturesRequest, type SimFeaturesRequest,
type SimReissueRequest,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
import { ZodValidationPipe } from "@bff/core/validation"; import { ZodValidationPipe } from "@bff/core/validation";
import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@ -52,12 +54,9 @@ export class SubscriptionsController {
async getSubscriptions( async getSubscriptions(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Query() query: SubscriptionQuery @Query() query: SubscriptionQuery
): Promise<SubscriptionList | Subscription[]> { ): Promise<SubscriptionList> {
if (query.status) { const { status } = query;
return this.subscriptionsService.getSubscriptionsByStatus(req.user.id, query.status); return this.subscriptionsService.getSubscriptions(req.user.id, { status });
}
return this.subscriptionsService.getSubscriptions(req.user.id);
} }
@Get("active") @Get("active")
@ -185,12 +184,13 @@ export class SubscriptionsController {
} }
@Post(":id/sim/reissue-esim") @Post(":id/sim/reissue-esim")
@UsePipes(new ZodValidationPipe(simReissueRequestSchema))
async reissueEsimProfile( async reissueEsimProfile(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: { newEid?: string } = {} @Body() body: SimReissueRequest
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid); await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body);
return { success: true, message: "eSIM profile reissue completed successfully" }; return { success: true, message: "eSIM profile reissue completed successfully" };
} }

View File

@ -1,18 +1,22 @@
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions"; import {
Subscription,
SubscriptionList,
subscriptionListSchema,
subscriptionStatusSchema,
type SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing"; import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { z } from "zod";
import { subscriptionSchema } from "@customer-portal/domain/subscriptions";
import type { Providers } from "@customer-portal/domain/subscriptions"; import type { Providers } from "@customer-portal/domain/subscriptions";
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
export interface GetSubscriptionsOptions { export interface GetSubscriptionsOptions {
status?: string; status?: SubscriptionStatus;
} }
@Injectable() @Injectable()
@ -44,25 +48,18 @@ export class SubscriptionsService {
{ status } { status }
); );
const parsed = z const parsed = subscriptionListSchema.parse(subscriptionList);
.object({
subscriptions: z.array(subscriptionSchema),
totalCount: z.number(),
})
.safeParse(subscriptionList);
if (!parsed.success) { let subscriptions = parsed.subscriptions;
throw new Error(parsed.error.message); if (status) {
const normalizedStatus = subscriptionStatusSchema.parse(status);
subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus);
} }
const filtered = status return subscriptionListSchema.parse({
? parsed.data.subscriptions.filter(sub => sub.status.toLowerCase() === status.toLowerCase()) subscriptions,
: parsed.data.subscriptions; totalCount: subscriptions.length,
});
return {
subscriptions: filtered,
totalCount: filtered.length,
} satisfies SubscriptionList;
} catch (error) { } catch (error) {
this.logger.error(`Failed to get subscriptions for user ${userId}`, { this.logger.error(`Failed to get subscriptions for user ${userId}`, {
error: getErrorMessage(error), error: getErrorMessage(error),
@ -141,22 +138,10 @@ export class SubscriptionsService {
/** /**
* Get subscriptions by status * Get subscriptions by status
*/ */
async getSubscriptionsByStatus(userId: string, status: string): Promise<Subscription[]> { async getSubscriptionsByStatus(userId: string, status: SubscriptionStatus): Promise<Subscription[]> {
try { try {
// Validate status const normalizedStatus = subscriptionStatusSchema.parse(status);
const validStatuses = [ const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus });
"Active",
"Suspended",
"Terminated",
"Cancelled",
"Pending",
"Completed",
];
if (!validStatuses.includes(status)) {
throw new Error(`Invalid status. Must be one of: ${validStatuses.join(", ")}`);
}
const subscriptionList = await this.getSubscriptions(userId, { status });
return subscriptionList.subscriptions; return subscriptionList.subscriptions;
} catch (error) { } catch (error) {
this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, { this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, {

View File

@ -11,11 +11,14 @@ interface UsePaymentRefreshOptions {
refetch: () => Promise<{ data: PaymentMethodList | undefined }>; refetch: () => Promise<{ data: PaymentMethodList | undefined }>;
// When true, attaches focus/visibility listeners to refresh automatically // When true, attaches focus/visibility listeners to refresh automatically
attachFocusListeners?: boolean; attachFocusListeners?: boolean;
// Optional custom detector for whether payment methods exist
hasMethods?: (data?: PaymentMethodList | undefined) => boolean;
} }
export function usePaymentRefresh({ export function usePaymentRefresh({
refetch, refetch,
attachFocusListeners = false, attachFocusListeners = false,
hasMethods,
}: UsePaymentRefreshOptions) { }: UsePaymentRefreshOptions) {
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({ const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false, visible: false,
@ -35,7 +38,10 @@ export function usePaymentRefresh({
const result = await refetch(); const result = await refetch();
const parsed = paymentMethodListSchema.safeParse(result.data ?? null); const parsed = paymentMethodListSchema.safeParse(result.data ?? null);
const list = parsed.success ? parsed.data : { paymentMethods: [], totalCount: 0 }; const list = parsed.success ? parsed.data : { paymentMethods: [], totalCount: 0 };
const has = list.totalCount > 0 || list.paymentMethods.length > 0; const has =
typeof hasMethods === "function"
? hasMethods(parsed.success ? parsed.data : undefined)
: list.totalCount > 0 || list.paymentMethods.length > 0;
setToast({ setToast({
visible: true, visible: true,
text: has ? "Payment methods updated" : "No payment method found yet", text: has ? "Payment methods updated" : "No payment method found yet",

View File

@ -42,7 +42,8 @@ export function PaymentMethodsContainer() {
const result = await paymentMethodsQuery.refetch(); const result = await paymentMethodsQuery.refetch();
return { data: result.data }; return { data: result.data };
}, },
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0, hasMethods: data =>
Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
attachFocusListeners: true, attachFocusListeners: true,
}); });
@ -245,4 +246,4 @@ export function PaymentMethodsContainer() {
); );
} }
export default PaymentMethodsContainer; export default PaymentMethodsContainer;

View File

@ -185,7 +185,7 @@ export function OrderSummary({
<div key={index} className="flex justify-between"> <div key={index} className="flex justify-between">
<span className="text-gray-600">{String(fee.name)}</span> <span className="text-gray-600">{String(fee.name)}</span>
<span className="font-medium"> <span className="font-medium">
¥{getOneTimePrice(fee).toLocaleString()} one-time ¥{(fee.oneTimePrice ?? fee.unitPrice ?? 0).toLocaleString()} one-time
</span> </span>
</div> </div>
))} ))}

View File

@ -53,8 +53,17 @@ function getPriceLabel(installation: InternetInstallationCatalogItem): string {
if (!priceInfo) { if (!priceInfo) {
return "Price not available"; return "Price not available";
} }
const suffix = priceInfo.billingCycle === "Monthly" ? "/month" : " one-time"; const billingCycle = installation.billingCycle?.toLowerCase();
return `¥${priceInfo.amount.toLocaleString()}${suffix}`; if (billingCycle === "monthly" && priceInfo.monthly !== null) {
return `¥${priceInfo.monthly.toLocaleString()}/month`;
}
if (priceInfo.oneTime !== null) {
return `¥${priceInfo.oneTime.toLocaleString()} one-time`;
}
if (priceInfo.monthly !== null) {
return `¥${priceInfo.monthly.toLocaleString()}`;
}
return priceInfo.display || "Price not available";
} }
export function InstallationOptions({ export function InstallationOptions({

View File

@ -169,7 +169,3 @@ function calculateOneTimeTotal(
return total; return total;
} }
type InstallationTerm = NonNullable<
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
>;

View File

@ -1,17 +1,5 @@
import React from "react"; import React from "react";
import type { MnpData } from "@customer-portal/domain/sim";
interface MnpData {
reservationNumber: string;
expiryDate: string;
phoneNumber: string;
mvnoAccountNumber: string;
portingLastName: string;
portingFirstName: string;
portingLastNameKatakana: string;
portingFirstNameKatakana: string;
portingGender: "Male" | "Female" | "Corporate/Other" | "";
portingDateOfBirth: string;
}
interface MnpFormProps { interface MnpFormProps {
wantsMnp: boolean; wantsMnp: boolean;
@ -77,7 +65,7 @@ export function MnpForm({
<input <input
type="text" type="text"
id="reservationNumber" id="reservationNumber"
value={mnpData.reservationNumber} value={mnpData.reservationNumber ?? ""}
onChange={e => handleInputChange("reservationNumber", e.target.value)} onChange={e => handleInputChange("reservationNumber", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="10-digit reservation number" placeholder="10-digit reservation number"
@ -95,7 +83,7 @@ export function MnpForm({
<input <input
type="date" type="date"
id="expiryDate" id="expiryDate"
value={mnpData.expiryDate} value={mnpData.expiryDate ?? ""}
onChange={e => handleInputChange("expiryDate", e.target.value)} onChange={e => handleInputChange("expiryDate", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/> />
@ -112,7 +100,7 @@ export function MnpForm({
<input <input
type="tel" type="tel"
id="phoneNumber" id="phoneNumber"
value={mnpData.phoneNumber} value={mnpData.phoneNumber ?? ""}
onChange={e => handleInputChange("phoneNumber", e.target.value)} onChange={e => handleInputChange("phoneNumber", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="090-1234-5678" placeholder="090-1234-5678"
@ -133,7 +121,7 @@ export function MnpForm({
<input <input
type="text" type="text"
id="mvnoAccountNumber" id="mvnoAccountNumber"
value={mnpData.mvnoAccountNumber} value={mnpData.mvnoAccountNumber ?? ""}
onChange={e => handleInputChange("mvnoAccountNumber", e.target.value)} onChange={e => handleInputChange("mvnoAccountNumber", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Your current carrier account number" placeholder="Your current carrier account number"
@ -151,7 +139,7 @@ export function MnpForm({
<input <input
type="text" type="text"
id="portingLastName" id="portingLastName"
value={mnpData.portingLastName} value={mnpData.portingLastName ?? ""}
onChange={e => handleInputChange("portingLastName", e.target.value)} onChange={e => handleInputChange("portingLastName", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Tanaka" placeholder="Tanaka"
@ -172,7 +160,7 @@ export function MnpForm({
<input <input
type="text" type="text"
id="portingFirstName" id="portingFirstName"
value={mnpData.portingFirstName} value={mnpData.portingFirstName ?? ""}
onChange={e => handleInputChange("portingFirstName", e.target.value)} onChange={e => handleInputChange("portingFirstName", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Taro" placeholder="Taro"
@ -193,7 +181,7 @@ export function MnpForm({
<input <input
type="text" type="text"
id="portingLastNameKatakana" id="portingLastNameKatakana"
value={mnpData.portingLastNameKatakana} value={mnpData.portingLastNameKatakana ?? ""}
onChange={e => handleInputChange("portingLastNameKatakana", e.target.value)} onChange={e => handleInputChange("portingLastNameKatakana", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="タナカ" placeholder="タナカ"
@ -214,7 +202,7 @@ export function MnpForm({
<input <input
type="text" type="text"
id="portingFirstNameKatakana" id="portingFirstNameKatakana"
value={mnpData.portingFirstNameKatakana} value={mnpData.portingFirstNameKatakana ?? ""}
onChange={e => handleInputChange("portingFirstNameKatakana", e.target.value)} onChange={e => handleInputChange("portingFirstNameKatakana", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="タロウ" placeholder="タロウ"
@ -234,10 +222,8 @@ export function MnpForm({
</label> </label>
<select <select
id="portingGender" id="portingGender"
value={mnpData.portingGender} value={mnpData.portingGender ?? ""}
onChange={e => onChange={e => handleInputChange("portingGender", e.target.value)}
handleInputChange("portingGender", e.target.value as MnpData["portingGender"])
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
> >
<option value="">Select gender</option> <option value="">Select gender</option>
@ -261,7 +247,7 @@ export function MnpForm({
<input <input
type="date" type="date"
id="portingDateOfBirth" id="portingDateOfBirth"
value={mnpData.portingDateOfBirth} value={mnpData.portingDateOfBirth ?? ""}
onChange={e => handleInputChange("portingDateOfBirth", e.target.value)} onChange={e => handleInputChange("portingDateOfBirth", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/> />

View File

@ -31,24 +31,48 @@ export function useSimConfigureParams() {
activationTypeParam === "Immediate" || activationTypeParam === "Scheduled" activationTypeParam === "Immediate" || activationTypeParam === "Scheduled"
? activationTypeParam ? activationTypeParam
: null; : null;
const scheduledAt = params.get("scheduledAt"); const scheduledAt = params.get("scheduledAt") ?? params.get("scheduledDate");
const addonSkus = params.getAll("addonSku"); const addonSkus = params.getAll("addonSku");
const isMnp = params.get("isMnp") === "true"; const isMnpParam = params.get("isMnp") ?? params.get("wantsMnp");
const isMnp = isMnpParam === "true";
// Optional detailed MNP fields if present // Optional detailed MNP fields if present
const mnp = { const mnp = {
reservationNumber: params.get("reservationNumber") || undefined, reservationNumber:
expiryDate: params.get("expiryDate") || undefined, params.get("mnpNumber") ??
phoneNumber: params.get("phoneNumber") || undefined, params.get("reservationNumber") ??
mvnoAccountNumber: params.get("mvnoAccountNumber") || undefined, params.get("mnp_reservationNumber") ??
portingLastName: params.get("portingLastName") || undefined,
portingFirstName: params.get("portingFirstName") || undefined,
portingLastNameKatakana: params.get("portingLastNameKatakana") || undefined,
portingFirstNameKatakana: params.get("portingFirstNameKatakana") || undefined,
portingGender:
(params.get("portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ||
undefined, undefined,
portingDateOfBirth: params.get("portingDateOfBirth") || undefined, expiryDate:
params.get("mnpExpiry") ??
params.get("expiryDate") ??
params.get("mnp_expiryDate") ??
undefined,
phoneNumber:
params.get("mnpPhone") ??
params.get("phoneNumber") ??
params.get("mnp_phoneNumber") ??
undefined,
mvnoAccountNumber:
params.get("mvnoAccountNumber") ?? params.get("mnp_mvnoAccountNumber") ?? undefined,
portingLastName:
params.get("portingLastName") ?? params.get("mnp_portingLastName") ?? undefined,
portingFirstName:
params.get("portingFirstName") ?? params.get("mnp_portingFirstName") ?? undefined,
portingLastNameKatakana:
params.get("portingLastNameKatakana") ??
params.get("mnp_portingLastNameKatakana") ??
undefined,
portingFirstNameKatakana:
params.get("portingFirstNameKatakana") ??
params.get("mnp_portingFirstNameKatakana") ??
undefined,
portingGender:
(params.get("portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ??
(params.get("mnp_portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ??
undefined,
portingDateOfBirth:
params.get("portingDateOfBirth") ?? params.get("mnp_portingDateOfBirth") ?? undefined,
} as const; } as const;
return { return {

View File

@ -6,12 +6,12 @@ import { useSimCatalog, useSimPlan, useSimConfigureParams } from ".";
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
import { import {
simConfigureFormSchema, simConfigureFormSchema,
simConfigureFormToRequest,
type SimConfigureFormData, type SimConfigureFormData,
type SimType, type SimCardType,
type ActivationType, type ActivationType,
type MnpData, type MnpData,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/sim";
import { buildSimOrderConfigurations } from "@customer-portal/domain/orders";
import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain/catalog"; import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain/catalog";
export type UseSimConfigureResult = { export type UseSimConfigureResult = {
@ -31,8 +31,8 @@ export type UseSimConfigureResult = {
validate: () => boolean; validate: () => boolean;
// Convenience getters for specific fields // Convenience getters for specific fields
simType: SimType; simType: SimCardType;
setSimType: (value: SimType) => void; setSimType: (value: SimCardType) => void;
eid: string; eid: string;
setEid: (value: string) => void; setEid: (value: string) => void;
selectedAddons: string[]; selectedAddons: string[];
@ -63,6 +63,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { data: simData, isLoading: simLoading } = useSimCatalog(); const { data: simData, isLoading: simLoading } = useSimCatalog();
const { plan: selectedPlan } = useSimPlan(planId); const { plan: selectedPlan } = useSimPlan(planId);
const configureParams = useSimConfigureParams();
// Step orchestration state // Step orchestration state
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
@ -93,13 +94,10 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const { values, errors, setValue, validate } = useZodForm<SimConfigureFormData>({ const { values, errors, setValue, validate } = useZodForm<SimConfigureFormData>({
schema: simConfigureFormSchema, schema: simConfigureFormSchema,
initialValues, initialValues,
onSubmit: data => {
simConfigureFormToRequest(data);
},
}); });
// Convenience setters that update the Zod form // Convenience setters that update the Zod form
const setSimType = useCallback((value: SimType) => setValue("simType", value), [setValue]); const setSimType = useCallback((value: SimCardType) => setValue("simType", value), [setValue]);
const setEid = useCallback((value: string) => setValue("eid", value), [setValue]); const setEid = useCallback((value: string) => setValue("eid", value), [setValue]);
const setSelectedAddons = useCallback( const setSelectedAddons = useCallback(
(value: SimConfigureFormData["selectedAddons"]) => setValue("selectedAddons", value), (value: SimConfigureFormData["selectedAddons"]) => setValue("selectedAddons", value),
@ -124,16 +122,70 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
if (mounted) { if (mounted) {
// Set initial values from URL params or defaults // Set initial values from URL params or defaults
const initialSimType = (searchParams.get("simType") as SimType) || "eSIM"; const initialSimType =
(configureParams.simType as SimCardType | null) ??
(searchParams.get("simType") as SimCardType) ??
"eSIM";
const initialActivationType = const initialActivationType =
(searchParams.get("activationType") as ActivationType) || "Immediate"; (configureParams.activationType as ActivationType | null) ??
(searchParams.get("activationType") as ActivationType) ??
"Immediate";
setSimType(initialSimType); setSimType(initialSimType);
setEid(searchParams.get("eid") || ""); setEid(configureParams.eid ?? searchParams.get("eid") ?? "");
setSelectedAddons(searchParams.get("addons")?.split(",").filter(Boolean) || []); const addonSkuSet = new Set<string>();
configureParams.addonSkus.forEach(sku => addonSkuSet.add(sku));
searchParams
.get("addons")
?.split(",")
.filter(Boolean)
.forEach(sku => addonSkuSet.add(sku));
setSelectedAddons(Array.from(addonSkuSet));
setActivationType(initialActivationType); setActivationType(initialActivationType);
setScheduledActivationDate(searchParams.get("scheduledDate") || ""); const scheduledAt =
setWantsMnp(searchParams.get("wantsMnp") === "true"); configureParams.scheduledAt ??
searchParams.get("scheduledAt") ??
searchParams.get("scheduledDate") ??
"";
if (scheduledAt) {
setScheduledActivationDate(
scheduledAt.includes("-")
? scheduledAt
: `${scheduledAt.slice(0, 4)}-${scheduledAt.slice(4, 6)}-${scheduledAt.slice(6, 8)}`
);
}
const wantsMnp = configureParams.isMnp;
setWantsMnp(wantsMnp);
if (wantsMnp) {
const mnp = configureParams.mnp;
setMnpData({
reservationNumber: mnp.reservationNumber ?? "",
expiryDate: mnp.expiryDate ?? "",
phoneNumber: mnp.phoneNumber ?? "",
mvnoAccountNumber: mnp.mvnoAccountNumber ?? "",
portingLastName: mnp.portingLastName ?? "",
portingFirstName: mnp.portingFirstName ?? "",
portingLastNameKatakana: mnp.portingLastNameKatakana ?? "",
portingFirstNameKatakana: mnp.portingFirstNameKatakana ?? "",
portingGender: mnp.portingGender ?? "",
portingDateOfBirth: mnp.portingDateOfBirth ?? "",
});
} else {
setMnpData({
reservationNumber: "",
expiryDate: "",
phoneNumber: "",
mvnoAccountNumber: "",
portingLastName: "",
portingFirstName: "",
portingLastNameKatakana: "",
portingFirstNameKatakana: "",
portingGender: "",
portingDateOfBirth: "",
});
}
} }
}; };
@ -144,14 +196,15 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
}, [ }, [
simLoading, simLoading,
simData, simData,
selectedPlan,
searchParams, searchParams,
configureParams,
setSimType, setSimType,
setEid, setEid,
setSelectedAddons, setSelectedAddons,
setActivationType, setActivationType,
setScheduledActivationDate, setScheduledActivationDate,
setWantsMnp, setWantsMnp,
setMnpData,
]); ]);
// Step transition handler (memoized) // Step transition handler (memoized)
@ -227,15 +280,57 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
params.set("activationType", values.activationType); params.set("activationType", values.activationType);
if (values.scheduledActivationDate) { if (values.scheduledActivationDate) {
params.set("scheduledDate", values.scheduledActivationDate); params.set("scheduledDate", values.scheduledActivationDate);
params.set(
"scheduledAt",
values.scheduledActivationDate.replace(/-/g, "")
);
} else {
params.delete("scheduledDate");
params.delete("scheduledAt");
} }
if (values.wantsMnp) { const simConfig = buildSimOrderConfigurations(values);
params.set("wantsMnp", "true"); params.set("simConfig", JSON.stringify(simConfig));
if (values.mnpData) {
Object.entries(values.mnpData).forEach(([key, value]) => { if (simConfig.scheduledAt && !values.scheduledActivationDate) {
if (value) params.set(`mnp_${key}`, value.toString()); params.set("scheduledAt", simConfig.scheduledAt);
}); }
}
params.set("wantsMnp", simConfig.isMnp === "true" ? "true" : "false");
if (simConfig.isMnp === "true") {
params.set("isMnp", "true");
if (simConfig.mnpNumber) params.set("mnpNumber", simConfig.mnpNumber);
if (simConfig.mnpExpiry) params.set("mnpExpiry", simConfig.mnpExpiry);
if (simConfig.mnpPhone) params.set("mnpPhone", simConfig.mnpPhone);
if (simConfig.mvnoAccountNumber)
params.set("mvnoAccountNumber", simConfig.mvnoAccountNumber);
if (simConfig.portingLastName)
params.set("portingLastName", simConfig.portingLastName);
if (simConfig.portingFirstName)
params.set("portingFirstName", simConfig.portingFirstName);
if (simConfig.portingLastNameKatakana)
params.set("portingLastNameKatakana", simConfig.portingLastNameKatakana);
if (simConfig.portingFirstNameKatakana)
params.set("portingFirstNameKatakana", simConfig.portingFirstNameKatakana);
if (simConfig.portingGender)
params.set("portingGender", simConfig.portingGender);
if (simConfig.portingDateOfBirth)
params.set("portingDateOfBirth", simConfig.portingDateOfBirth);
} else {
params.set("isMnp", "false");
[
"mnpNumber",
"mnpExpiry",
"mnpPhone",
"mvnoAccountNumber",
"portingLastName",
"portingFirstName",
"portingLastNameKatakana",
"portingFirstNameKatakana",
"portingGender",
"portingDateOfBirth",
].forEach(key => params.delete(key));
} }
} }

View File

@ -10,7 +10,12 @@ import type { CatalogProductBase } from "@customer-portal/domain/catalog";
import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit"; import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit";
import type { AsyncState } from "@customer-portal/domain/toolkit"; import type { AsyncState } from "@customer-portal/domain/toolkit";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import { ORDER_TYPE, type OrderConfigurations, type OrderTypeValue } from "@customer-portal/domain/orders"; import {
ORDER_TYPE,
orderConfigurationsSchema,
type OrderConfigurations,
type OrderTypeValue,
} from "@customer-portal/domain/orders";
// Use domain Address type // Use domain Address type
import type { Address } from "@customer-portal/domain/customer"; import type { Address } from "@customer-portal/domain/customer";
@ -84,6 +89,25 @@ export function useCheckout() {
return obj; return obj;
}, [params]); }, [params]);
const simConfig = useMemo(() => {
if (orderType !== ORDER_TYPE.SIM) {
return null;
}
const rawConfig = params.get("simConfig");
if (!rawConfig) {
return null;
}
try {
const parsed = JSON.parse(rawConfig) as unknown;
return orderConfigurationsSchema.parse(parsed);
} catch (error) {
console.warn("Failed to parse SIM order configuration from query params", error);
return null;
}
}, [orderType, params]);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@ -226,20 +250,20 @@ export function useCheckout() {
if (!mounted) return; if (!mounted) return;
const totals = calculateTotals(items); const totals = calculateTotals(items);
const configuration =
orderType === ORDER_TYPE.SIM && simConfig ? simConfig : ({} as OrderConfigurations);
setCheckoutState( setCheckoutState(
createSuccessState({ createSuccessState({
items, items,
totals, totals,
configuration: {} as OrderConfigurations, configuration,
}) })
); );
} catch (error) { } catch (error) {
if (mounted) { if (mounted) {
setCheckoutState( const reason =
createErrorState( error instanceof Error ? error.message : "Failed to load checkout data";
error instanceof Error ? error.message : "Failed to load checkout data" setCheckoutState(createErrorState(new Error(reason)));
)
);
} }
} }
})(); })();
@ -247,7 +271,7 @@ export function useCheckout() {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [orderType, params, selections]); }, [orderType, params, selections, simConfig]);
const handleSubmitOrder = useCallback(async () => { const handleSubmitOrder = useCallback(async () => {
try { try {
@ -266,59 +290,90 @@ export function useCheckout() {
throw new Error("No products selected for order. Please go back and select products."); throw new Error("No products selected for order. Please go back and select products.");
} }
const configurations: OrderConfigurations = { let configurationAccumulator: Partial<OrderConfigurations> = {};
...(selections.accessMode ? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] } : {}),
...(selections.activationType if (orderType === ORDER_TYPE.SIM) {
? { activationType: selections.activationType as OrderConfigurations["activationType"] } if (simConfig) {
: {}), configurationAccumulator = { ...simConfig };
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}), } else {
...(selections.simType ? { simType: selections.simType as OrderConfigurations["simType"] } : {}), configurationAccumulator = {
...(selections.eid ? { eid: selections.eid } : {}), ...(selections.simType
...(selections.isMnp ? { isMnp: selections.isMnp } : {}), ? { simType: selections.simType as OrderConfigurations["simType"] }
...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}), : {}),
...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}), ...(selections.activationType
...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}), ? { activationType: selections.activationType as OrderConfigurations["activationType"] }
...(selections.mvnoAccountNumber ? { mvnoAccountNumber: selections.mvnoAccountNumber } : {}), : {}),
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}), ...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}), ...(selections.eid ? { eid: selections.eid } : {}),
...(selections.portingLastNameKatakana ...(selections.isMnp ? { isMnp: selections.isMnp } : {}),
? { portingLastNameKatakana: selections.portingLastNameKatakana } ...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}),
: {}), ...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}),
...(selections.portingFirstNameKatakana ...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}),
? { portingFirstNameKatakana: selections.portingFirstNameKatakana } ...(selections.mvnoAccountNumber
: {}), ? { mvnoAccountNumber: selections.mvnoAccountNumber }
...(selections.portingGender : {}),
? { ...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
portingGender: selections.portingGender as OrderConfigurations["portingGender"], ...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}),
} ...(selections.portingLastNameKatakana
: {}), ? { portingLastNameKatakana: selections.portingLastNameKatakana }
...(selections.portingDateOfBirth ? { portingDateOfBirth: selections.portingDateOfBirth } : {}), : {}),
}; ...(selections.portingFirstNameKatakana
? { portingFirstNameKatakana: selections.portingFirstNameKatakana }
: {}),
...(selections.portingGender
? {
portingGender: selections.portingGender as OrderConfigurations["portingGender"],
}
: {}),
...(selections.portingDateOfBirth
? { portingDateOfBirth: selections.portingDateOfBirth }
: {}),
};
}
} else {
configurationAccumulator = {
...(selections.accessMode
? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] }
: {}),
...(selections.activationType
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
: {}),
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
};
}
if (confirmedAddress) { if (confirmedAddress) {
configurations.address = { configurationAccumulator.address = {
street: confirmedAddress.street ?? undefined, street: confirmedAddress.address1 ?? undefined,
streetLine2: confirmedAddress.streetLine2 ?? undefined, streetLine2: confirmedAddress.address2 ?? undefined,
city: confirmedAddress.city ?? undefined, city: confirmedAddress.city ?? undefined,
state: confirmedAddress.state ?? undefined, state: confirmedAddress.state ?? undefined,
postalCode: confirmedAddress.postalCode ?? undefined, postalCode: confirmedAddress.postcode ?? undefined,
country: confirmedAddress.country ?? undefined, country: confirmedAddress.country ?? undefined,
}; };
} }
const hasConfiguration = Object.keys(configurationAccumulator).length > 0;
const configurations = hasConfiguration
? orderConfigurationsSchema.parse(configurationAccumulator)
: undefined;
const orderData = { const orderData = {
orderType, orderType,
skus: uniqueSkus, skus: uniqueSkus,
...(Object.keys(configurations).length > 0 && { configurations }), ...(configurations ? { configurations } : {}),
}; };
if (orderType === "SIM") { if (orderType === ORDER_TYPE.SIM) {
if (!selections.eid && selections.simType === "eSIM") { if (!configurations) {
throw new Error("SIM configuration is incomplete. Please restart the SIM configuration flow.");
}
if (configurations?.simType === "eSIM" && !configurations.eid) {
throw new Error( throw new Error(
"EID is required for eSIM activation. Please go back and provide your EID." "EID is required for eSIM activation. Please go back and provide your EID."
); );
} }
if (!selections.phoneNumber && !selections.mnpPhone) { if (!configurations?.mnpPhone) {
throw new Error( throw new Error(
"Phone number is required for SIM activation. Please go back and provide a phone number." "Phone number is required for SIM activation. Please go back and provide a phone number."
); );
@ -340,16 +395,16 @@ export function useCheckout() {
} }
} }
const response = await ordersService.createOrder<{ sfOrderId: string }>(orderData); const response = await ordersService.createOrder(orderData);
router.push(`/orders/${response.sfOrderId}?status=success`); router.push(`/orders/${response.sfOrderId}?status=success`);
} catch (error) { } catch (error) {
let errorMessage = "Order submission failed"; let errorMessage = "Order submission failed";
if (error instanceof Error) errorMessage = error.message; if (error instanceof Error) errorMessage = error.message;
setCheckoutState(createErrorState(errorMessage)); setCheckoutState(createErrorState(new Error(errorMessage)));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [checkoutState, confirmedAddress, orderType, selections, router]); }, [checkoutState, confirmedAddress, orderType, selections, router, simConfig, activeSubs]);
const confirmAddress = useCallback((address?: Address) => { const confirmAddress = useCallback((address?: Address) => {
setAddressConfirmed(true); setAddressConfirmed(true);

View File

@ -55,7 +55,7 @@ export function CheckoutContainer() {
<div className="py-6"> <div className="py-6">
<AlertBanner variant="error" title="Unable to load checkout" elevated> <AlertBanner variant="error" title="Unable to load checkout" elevated>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{checkoutState.error}</span> <span>{checkoutState.error.message}</span>
<Button variant="link" onClick={navigateBackToConfigure}> <Button variant="link" onClick={navigateBackToConfigure}>
Go Back Go Back
</Button> </Button>

View File

@ -1,6 +1,5 @@
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { import {
createOrderRequest,
orderDetailsSchema, orderDetailsSchema,
orderSummarySchema, orderSummarySchema,
type CreateOrderRequest, type CreateOrderRequest,
@ -13,11 +12,11 @@ import {
} from "@/lib/api/response-helpers"; } from "@/lib/api/response-helpers";
async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> { async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> {
const body = createOrderRequest({ const body: CreateOrderRequest = {
orderType: payload.orderType, orderType: payload.orderType,
skus: payload.skus, skus: payload.skus,
configurations: payload.configurations ?? undefined, ...(payload.configurations ? { configurations: payload.configurations } : {}),
}); };
const response = await apiClient.POST("/api/orders", { body }); const response = await apiClient.POST("/api/orders", { body });
const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>( const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>(
response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }> response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }>

View File

@ -55,7 +55,7 @@ export function OrderDetailContainer() {
? deriveOrderStatusDescriptor({ ? deriveOrderStatusDescriptor({
status: data.status, status: data.status,
activationStatus: data.activationStatus, activationStatus: data.activationStatus,
scheduledAt: data.scheduledAt, scheduledAt: data.activationScheduledAt,
}) })
: null; : null;
@ -68,7 +68,7 @@ export function OrderDetailContainer() {
const totals = calculateOrderTotals( const totals = calculateOrderTotals(
data?.items?.map(item => ({ data?.items?.map(item => ({
totalPrice: item.totalPrice, totalPrice: item.totalPrice,
billingCycle: item.product?.billingCycle, billingCycle: item.billingCycle,
})), })),
data?.totalAmount data?.totalAmount
); );
@ -176,7 +176,7 @@ export function OrderDetailContainer() {
{data.items.map(item => { {data.items.map(item => {
const productName = item.product?.name ?? "Product"; const productName = item.product?.name ?? "Product";
const sku = item.product?.sku ?? "N/A"; const sku = item.product?.sku ?? "N/A";
const billingCycle = item.product?.billingCycle ?? ""; const billingCycle = item.billingCycle ?? "";
return ( return (
<div <div

View File

@ -4,12 +4,10 @@ import React from "react";
import { formatPlanShort } from "@/lib/utils"; import { formatPlanShort } from "@/lib/utils";
import { import {
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
WifiIcon,
SignalIcon,
ClockIcon,
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
XCircleIcon, XCircleIcon,
ClockIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import type { SimDetails } from "@customer-portal/domain/sim"; import type { SimDetails } from "@customer-portal/domain/sim";
@ -17,363 +15,206 @@ interface SimDetailsCardProps {
simDetails: SimDetails; simDetails: SimDetails;
isLoading?: boolean; isLoading?: boolean;
error?: string | null; error?: string | null;
embedded?: boolean; // when true, render content without card container embedded?: boolean;
showFeaturesSummary?: boolean; // show the right-side Service Features summary showFeaturesSummary?: boolean;
} }
const statusIconMap: Record<string, React.ReactNode> = {
active: <CheckCircleIcon className="h-5 w-5 text-green-500" />,
suspended: <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />,
cancelled: <XCircleIcon className="h-5 w-5 text-red-500" />,
pending: <ClockIcon className="h-5 w-5 text-blue-500" />,
};
const statusBadgeClass: Record<string, string> = {
active: "bg-green-100 text-green-800",
suspended: "bg-yellow-100 text-yellow-800",
cancelled: "bg-red-100 text-red-800",
pending: "bg-blue-100 text-blue-800",
};
const formatDate = (value?: string | null) => {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime())
? value
: date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
};
const formatQuota = (remainingMb: number) => {
if (remainingMb >= 1000) {
return `${(remainingMb / 1000).toFixed(1)} GB`;
}
return `${remainingMb.toFixed(0)} MB`;
};
const FeatureToggleRow = ({
label,
enabled,
}: {
label: string;
enabled: boolean;
}) => (
<div className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">{label}</span>
<span
className={`text-xs font-semibold px-2 py-1 rounded-full ${
enabled ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
}`}
>
{enabled ? "Enabled" : "Disabled"}
</span>
</div>
);
const LoadingCard = ({ embedded }: { embedded: boolean }) => (
<div
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-gray-100"} p-6 lg:p-8`}
>
<div className="animate-pulse space-y-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-full bg-gray-200" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-1/3" />
</div>
</div>
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-24 bg-gray-200 rounded" />
<div className="h-24 bg-gray-200 rounded" />
</div>
</div>
</div>
);
const ErrorCard = ({ embedded, message }: { embedded: boolean; message: string }) => (
<div
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-red-100"} p-6 lg:p-8`}
>
<div className="text-center text-red-600 text-sm">{message}</div>
</div>
);
export function SimDetailsCard({ export function SimDetailsCard({
simDetails, simDetails,
isLoading, isLoading = false,
error, error = null,
embedded = false, embedded = false,
showFeaturesSummary = true, showFeaturesSummary = true,
}: SimDetailsCardProps) { }: SimDetailsCardProps) {
const formatPlan = (code?: string) => formatPlanShort(code);
const isEsim = simDetails.simType === "esim";
const hasVoice = Boolean(simDetails.hasVoice ?? simDetails.voiceMailEnabled);
const hasSms = Boolean(simDetails.hasSms);
const voiceMailEnabled = Boolean(simDetails.voiceMailEnabled);
const callWaitingEnabled = Boolean(simDetails.callWaitingEnabled);
const internationalRoamingEnabled = Boolean(simDetails.internationalRoamingEnabled);
const sizeLabel = simDetails.size ?? simDetails.simType;
const ipv4Address = simDetails.ipv4;
const ipv6Address = simDetails.ipv6;
const getStatusIcon = (status: string) => {
switch (status) {
case "active":
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
case "suspended":
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
case "cancelled":
return <XCircleIcon className="h-6 w-6 text-red-500" />;
case "pending":
return <ClockIcon className="h-6 w-6 text-blue-500" />;
default:
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "active":
return "bg-green-100 text-green-800";
case "suspended":
return "bg-yellow-100 text-yellow-800";
case "cancelled":
return "bg-red-100 text-red-800";
case "pending":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return dateString;
}
};
const formatQuota = (quotaMb: number) => {
if (quotaMb >= 1000) {
return `${(quotaMb / 1000).toFixed(1)} GB`;
}
return `${quotaMb.toFixed(0)} MB`;
};
if (isLoading) { if (isLoading) {
const Skeleton = ( return <LoadingCard embedded={embedded} />;
<div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 "}p-6 lg:p-8`}
>
<div className="animate-pulse">
<div className="flex items-center space-x-4">
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
<div className="flex-1 space-y-3">
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
</div>
</div>
<div className="mt-8 space-y-4">
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-5/6"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-4/6"></div>
</div>
</div>
</div>
);
return Skeleton;
} }
if (error) { if (error) {
return ( return <ErrorCard embedded={embedded} message={error} />;
<div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-red-100 "}p-6 lg:p-8`}
>
<div className="text-center">
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error Loading SIM Details</h3>
<p className="text-red-600 text-sm">{error}</p>
</div>
</div>
);
} }
// Specialized, minimal eSIM details view const planName = simDetails.planName || formatPlanShort(simDetails.planCode) || "SIM Plan";
if (simDetails.simType === "esim") { const normalizedStatus = simDetails.status?.toLowerCase() ?? "unknown";
return ( const statusIcon = statusIconMap[normalizedStatus] ?? (
<div <DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`} );
> const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800";
{/* Header */} const containerClasses = embedded
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}> ? ""
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0"> : "bg-white shadow-lg rounded-xl border border-gray-100";
<div className="flex items-center">
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<WifiIcon className="h-8 w-8 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3>
<p className="text-sm text-gray-600 font-medium">
Current Plan: {formatPlan(simDetails.planCode)}
</p>
</div>
</div>
<span
className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span>
</div>
</div>
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6">
<div>
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
<DevicePhoneMobileIcon className="h-4 w-4 mr-2 text-blue-500" />
SIM Information
</h4>
<div className="bg-gray-50 rounded-lg p-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Phone Number
</label>
<p className="text-lg font-semibold text-gray-900 mt-1">{simDetails.msisdn}</p>
</div>
</div>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Data Remaining
</label>
<p className="text-2xl font-bold text-green-600 mt-1">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div>
</div>
{showFeaturesSummary && (
<div>
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
<CheckCircleIcon className="h-4 w-4 mr-2 text-green-500" />
Service Features
</h4>
<div className="space-y-3">
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Voice Mail (¥300/month)</span>
<span
className={`text-sm font-semibold px-2 py-1 rounded-full ${voiceMailEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{voiceMailEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Call Waiting (¥300/month)</span>
<span
className={`text-sm font-semibold px-2 py-1 rounded-full ${callWaitingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{callWaitingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">International Roaming</span>
<span
className={`text-sm font-semibold px-2 py-1 rounded-full ${internationalRoamingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{internationalRoamingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-blue-50 rounded-lg">
<span className="text-sm text-gray-700">4G/5G</span>
<span className="text-sm font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800">
{simDetails.networkType || "5G"}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
return ( return (
<div className={`${embedded ? "" : "bg-white shadow rounded-lg"}`}> <div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}>
{/* Header */} <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}> <div className="flex items-center gap-3">
<div className="flex items-center justify-between"> <div className="rounded-xl bg-blue-50 p-3">
<div className="flex items-center"> <DevicePhoneMobileIcon className="h-7 w-7 text-blue-600" />
<div className="text-2xl mr-3">
<DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500">
{formatPlan(simDetails.planCode)} {`${sizeLabel ?? "Unknown"} SIM`}
</p>
</div>
</div> </div>
<div className="flex items-center space-x-3"> <div>
{getStatusIcon(simDetails.status)} <h3 className="text-xl font-semibold text-gray-900">{planName}</h3>
<span <p className="text-sm text-gray-600">Account #{simDetails.account}</p>
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span>
</div> </div>
</div> </div>
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full ${statusClass}`}>
{statusIcon}
<span className="text-sm font-medium capitalize">{simDetails.status}</span>
</span>
</div> </div>
{/* Content */} <div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className={`${embedded ? "" : "px-6 py-4"}`}> <div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <section className="bg-gray-50 rounded-lg p-4">
{/* SIM Information */} <h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
SIM Information SIM Information
</h4> </h4>
<div className="space-y-3"> <dl className="space-y-2 text-sm text-gray-700">
<div> <div className="flex justify-between">
<label className="text-xs text-gray-500">Phone Number</label> <dt className="font-medium text-gray-600">Phone Number</dt>
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p> <dd className="font-semibold text-gray-900">{simDetails.msisdn}</dd>
</div>
<div className="flex justify-between">
<dt className="font-medium text-gray-600">SIM Type</dt>
<dd className="font-semibold text-gray-900">{simDetails.simType}</dd>
</div>
<div className="flex justify-between">
<dt className="font-medium text-gray-600">ICCID</dt>
<dd className="font-mono text-gray-900 break-all">{simDetails.iccid}</dd>
</div> </div>
{simDetails.simType === "physical" && (
<div>
<label className="text-xs text-gray-500">ICCID</label>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
</div>
)}
{simDetails.eid && ( {simDetails.eid && (
<div> <div className="flex justify-between">
<label className="text-xs text-gray-500">EID (eSIM)</label> <dt className="font-medium text-gray-600">EID</dt>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p> <dd className="font-mono text-gray-900 break-all">{simDetails.eid}</dd>
</div> </div>
)} )}
<div className="flex justify-between">
{simDetails.imsi && ( <dt className="font-medium text-gray-600">Network Type</dt>
<div> <dd className="font-semibold text-gray-900">{simDetails.networkType}</dd>
<label className="text-xs text-gray-500">IMSI</label>
<p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
</div>
)}
{simDetails.startDate && (
<div>
<label className="text-xs text-gray-500">Service Start Date</label>
<p className="text-sm text-gray-900">{formatDate(simDetails.startDate)}</p>
</div>
)}
</div>
</div>
{/* Service Features */}
{showFeaturesSummary && (
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
Service Features
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Data Remaining</label>
<p className="text-lg font-semibold text-green-600">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center">
<SignalIcon
className={`h-4 w-4 mr-1 ${hasVoice ? "text-green-500" : "text-gray-400"}`}
/>
<span className={`text-sm ${hasVoice ? "text-green-600" : "text-gray-500"}`}>
Voice {hasVoice ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex items-center">
<DevicePhoneMobileIcon
className={`h-4 w-4 mr-1 ${hasSms ? "text-green-500" : "text-gray-400"}`}
/>
<span className={`text-sm ${hasSms ? "text-green-600" : "text-gray-500"}`}>
SMS {hasSms ? "Enabled" : "Disabled"}
</span>
</div>
</div>
{(simDetails.ipv4 || simDetails.ipv6) && (
<div>
<label className="text-xs text-gray-500">IP Address</label>
<div className="space-y-1">
{ipv4Address && (
<p className="text-sm font-mono text-gray-900">IPv4: {ipv4Address}</p>
)}
{ipv6Address && (
<p className="text-sm font-mono text-gray-900">IPv6: {ipv6Address}</p>
)}
{!ipv4Address && !ipv6Address && (
<p className="text-sm text-gray-500">No IP assigned</p>
)}
</div>
</div>
)}
</div> </div>
</div> </dl>
)} </section>
<section className="bg-gray-50 rounded-lg p-4">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
Data Remaining
</h4>
<p className="text-2xl font-bold text-green-600">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
<p className="text-xs text-gray-500 mt-1">Remaining allowance in current cycle</p>
</section>
<section className="bg-gray-50 rounded-lg p-4">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
Activation Timeline
</h4>
<dl className="space-y-2 text-sm text-gray-700">
<div className="flex justify-between">
<dt className="font-medium text-gray-600">Activated</dt>
<dd>{formatDate(simDetails.activatedAt)}</dd>
</div>
<div className="flex justify-between">
<dt className="font-medium text-gray-600">Expires</dt>
<dd>{formatDate(simDetails.expiresAt)}</dd>
</div>
</dl>
</section>
</div> </div>
{/* Pending Operations */} {showFeaturesSummary && (
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && ( <section className="space-y-3">
<div className="mt-6 pt-6 border-t border-gray-200"> <h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3"> Service Features
Pending Operations
</h4> </h4>
<div className="bg-blue-50 rounded-lg p-4"> <FeatureToggleRow label="Voice Mail" enabled={simDetails.voiceMailEnabled} />
{simDetails.pendingOperations.map((operation, index) => ( <FeatureToggleRow label="Call Waiting" enabled={simDetails.callWaitingEnabled} />
<div key={index} className="flex items-center text-sm"> <FeatureToggleRow
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" /> label="International Roaming"
<span className="text-blue-800"> enabled={simDetails.internationalRoamingEnabled}
{operation.operation} scheduled for {formatDate(operation.scheduledDate)} />
</span> </section>
</div>
))}
</div>
</div>
)} )}
</div> </div>
</div> </div>
); );
} }
export type { SimDetails };

View File

@ -6,11 +6,12 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
ArrowPathIcon, ArrowPathIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { SimDetailsCard, type SimDetails } from "./SimDetailsCard"; import { SimDetailsCard } from "./SimDetailsCard";
import { DataUsageChart, type SimUsage } from "./DataUsageChart"; import { DataUsageChart, type SimUsage } from "./DataUsageChart";
import { SimActions } from "./SimActions"; import { SimActions } from "./SimActions";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { SimFeatureToggles } from "./SimFeatureToggles"; import { SimFeatureToggles } from "./SimFeatureToggles";
import type { SimDetails } from "@customer-portal/domain/sim";
interface SimManagementSectionProps { interface SimManagementSectionProps {
subscriptionId: number; subscriptionId: number;
@ -137,6 +138,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
return null; return null;
} }
const actionSimType: "esim" | "physical" =
simInfo.details.simType.toLowerCase() === "esim" ? "esim" : "physical";
return ( return (
<div id="sim-management" className="space-y-8"> <div id="sim-management" className="space-y-8">
{/* SIM Details and Usage - Main Content */} {/* SIM Details and Usage - Main Content */}
@ -146,7 +150,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6"> <div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
<SimActions <SimActions
subscriptionId={subscriptionId} subscriptionId={subscriptionId}
simType={simInfo.details.simType} simType={actionSimType}
status={simInfo.details.status} status={simInfo.details.status}
currentPlanCode={simInfo.details.planCode} currentPlanCode={simInfo.details.planCode}
onTopUpSuccess={handleActionSuccess} onTopUpSuccess={handleActionSuccess}

View File

@ -148,7 +148,7 @@ export function SimCancelContainer() {
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<InfoRow label="SIM" value={details?.msisdn || "—"} /> <InfoRow label="SIM" value={details?.msisdn || "—"} />
<InfoRow label="Start Date" value={details?.startDate || "—"} /> <InfoRow label="Activated" value={details?.activatedAt || "—"} />
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Cancellation Month Cancellation Month

View File

@ -14,11 +14,6 @@ interface UseSubscriptionsOptions {
status?: string; status?: string;
} }
const emptySubscriptionList: SubscriptionList = {
subscriptions: [],
totalCount: 0,
};
const emptyStats = { const emptyStats = {
total: 0, total: 0,
active: 0, active: 0,
@ -35,19 +30,6 @@ const emptyInvoiceList: InvoiceList = {
}, },
}; };
function toSubscriptionList(payload?: SubscriptionList | Subscription[] | null): SubscriptionList {
if (!payload) {
return emptySubscriptionList;
}
if (Array.isArray(payload)) {
return {
subscriptions: payload,
totalCount: payload.length,
};
}
return payload;
}
/** /**
* Hook to fetch all subscriptions * Hook to fetch all subscriptions
*/ */
@ -62,9 +44,7 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
"/api/subscriptions", "/api/subscriptions",
status ? { params: { query: { status } } } : undefined status ? { params: { query: { status } } } : undefined
); );
return toSubscriptionList( return getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions");
getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions")
);
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000, gcTime: 10 * 60 * 1000,

View File

@ -146,7 +146,7 @@ export function SimCancelContainer() {
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<InfoRow label="SIM" value={details?.msisdn || "—"} /> <InfoRow label="SIM" value={details?.msisdn || "—"} />
<InfoRow label="Start Date" value={details?.startDate || "—"} /> <InfoRow label="Activated" value={details?.activatedAt || "—"} />
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Cancellation Month Cancellation Month

View File

@ -1,6 +1,6 @@
export { createClient, resolveBaseUrl } from "./runtime/client"; export { createClient, resolveBaseUrl } from "./runtime/client";
export type { ApiClient, AuthHeaderResolver, CreateClientOptions, QueryParams, PathParams } from "./runtime/client"; export type { ApiClient, AuthHeaderResolver, CreateClientOptions, QueryParams, PathParams } from "./runtime/client";
export { ApiError } from "./runtime/client"; export { ApiError, isApiError } from "./runtime/client";
// Re-export API helpers // Re-export API helpers
export * from "./response-helpers"; export * from "./response-helpers";

View File

@ -22,7 +22,7 @@ export type HttpMethod =
| "HEAD" | "HEAD"
| "OPTIONS"; | "OPTIONS";
type PathParams = Record<string, string | number>; export type PathParams = Record<string, string | number>;
export type QueryPrimitive = string | number | boolean; export type QueryPrimitive = string | number | boolean;
export type QueryParams = Record< export type QueryParams = Record<
string, string,
@ -383,8 +383,12 @@ export function createClient(options: CreateClientOptions = {}): ApiClient {
const parsedBody = await parseResponseBody(response); const parsedBody = await parseResponseBody(response);
if (parsedBody === undefined || parsedBody === null) {
return {};
}
return { return {
data: (parsedBody as T | null | undefined) ?? null, data: parsedBody as T,
}; };
}; };

View File

@ -0,0 +1,71 @@
import { orderConfigurationsSchema, type OrderConfigurations } from "./schema";
import type { SimConfigureFormData } from "../sim";
export interface BuildSimOrderConfigurationsOptions {
/**
* Optional fallback phone number when the SIM form does not include it directly.
* Useful for flows where the phone number is collected separately.
*/
phoneNumber?: string | null | undefined;
}
const normalizeString = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const normalizeDate = (value: unknown): string | undefined => {
const str = normalizeString(value);
if (!str) return undefined;
return str.replace(/-/g, "");
};
/**
* Build an OrderConfigurations object for SIM orders from the shared SimConfigureFormData.
* Ensures the resulting payload conforms to the domain schema before it is sent to the BFF.
*/
export function buildSimOrderConfigurations(
formData: SimConfigureFormData,
options: BuildSimOrderConfigurationsOptions = {}
): OrderConfigurations {
const base: Record<string, unknown> = {
simType: formData.simType,
activationType: formData.activationType,
};
const eid = normalizeString(formData.eid);
if (formData.simType === "eSIM" && eid) {
base.eid = eid;
}
const scheduledDate = normalizeDate(formData.scheduledActivationDate);
if (formData.activationType === "Scheduled" && scheduledDate) {
base.scheduledAt = scheduledDate;
}
const phoneCandidate =
normalizeString(formData.mnpData?.phoneNumber) ?? normalizeString(options.phoneNumber);
if (phoneCandidate) {
base.mnpPhone = phoneCandidate;
}
if (formData.wantsMnp && formData.mnpData) {
const mnp = formData.mnpData;
base.isMnp = "true";
base.mnpNumber = normalizeString(mnp.reservationNumber);
base.mnpExpiry = normalizeDate(mnp.expiryDate);
base.mvnoAccountNumber = normalizeString(mnp.mvnoAccountNumber);
base.portingLastName = normalizeString(mnp.portingLastName);
base.portingFirstName = normalizeString(mnp.portingFirstName);
base.portingLastNameKatakana = normalizeString(mnp.portingLastNameKatakana);
base.portingFirstNameKatakana = normalizeString(mnp.portingFirstNameKatakana);
base.portingGender = normalizeString(mnp.portingGender);
base.portingDateOfBirth = normalizeDate(mnp.portingDateOfBirth);
} else if (formData.wantsMnp) {
// When wantsMnp is true but data is missing, mark the flag so validation fails clearly downstream.
base.isMnp = "true";
}
return orderConfigurationsSchema.parse(base);
}

View File

@ -11,6 +11,7 @@ export {
type OrderCreationType, type OrderCreationType,
type OrderStatus, type OrderStatus,
type OrderType, type OrderType,
type OrderTypeValue,
type UserMapping, type UserMapping,
// Constants // Constants
ORDER_TYPE, ORDER_TYPE,
@ -29,6 +30,10 @@ export * from "./validation";
// Utilities // Utilities
export * from "./utils"; export * from "./utils";
export {
buildSimOrderConfigurations,
type BuildSimOrderConfigurationsOptions,
} from "./helpers";
// Re-export types for convenience // Re-export types for convenience
export type { export type {

View File

@ -30,6 +30,11 @@ export type {
SimCancelRequest, SimCancelRequest,
SimTopUpHistoryRequest, SimTopUpHistoryRequest,
SimFeaturesUpdateRequest, SimFeaturesUpdateRequest,
SimReissueRequest,
SimConfigureFormData,
SimCardType,
ActivationType,
MnpData,
// Activation types // Activation types
SimOrderActivationRequest, SimOrderActivationRequest,
SimOrderActivationMnp, SimOrderActivationMnp,

View File

@ -111,6 +111,95 @@ export const simReissueRequestSchema = z.object({
.optional(), .optional(),
}); });
const simMnpFormSchema = z.object({
reservationNumber: z.string().min(1, "Reservation number is required"),
expiryDate: z.string().regex(/^\d{8}$/, "Expiry date must be in YYYYMMDD format"),
phoneNumber: z.string().min(1, "Phone number is required"),
mvnoAccountNumber: z.string().optional(),
portingLastName: z.string().optional(),
portingFirstName: z.string().optional(),
portingLastNameKatakana: z.string().optional(),
portingFirstNameKatakana: z.string().optional(),
portingGender: z.string().optional(),
portingDateOfBirth: z
.string()
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
.optional(),
});
export const simCardTypeSchema = z.enum(["eSIM", "Physical SIM"]);
export const simActivationTypeSchema = z.enum(["Immediate", "Scheduled"]);
export const simConfigureFormSchema = z
.object({
simType: simCardTypeSchema,
eid: z
.string()
.min(15, "EID must be at least 15 characters")
.max(32, "EID must be at most 32 characters")
.optional(),
selectedAddons: z.array(z.string()).default([]),
activationType: simActivationTypeSchema,
scheduledActivationDate: z
.string()
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
.optional(),
wantsMnp: z.boolean().default(false),
mnpData: simMnpFormSchema.optional(),
})
.superRefine((data, ctx) => {
if (data.simType === "eSIM" && (!data.eid || data.eid.trim().length < 15)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["eid"],
message: "EID is required for eSIM configuration",
});
}
if (data.activationType === "Scheduled" && !data.scheduledActivationDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["scheduledActivationDate"],
message: "Scheduled activation date is required when activation type is Scheduled",
});
}
if (data.wantsMnp) {
if (!data.mnpData) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["mnpData"],
message: "MNP data is required when porting is selected",
});
return;
}
const { reservationNumber, expiryDate, phoneNumber } = data.mnpData;
if (!reservationNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["mnpData", "reservationNumber"],
message: "Reservation number is required",
});
}
if (!expiryDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["mnpData", "expiryDate"],
message: "Reservation expiry date is required",
});
}
if (!phoneNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["mnpData", "phoneNumber"],
message: "Phone number is required for porting",
});
}
}
});
// ============================================================================ // ============================================================================
// SIM Order Activation Schemas // SIM Order Activation Schemas
// ============================================================================ // ============================================================================
@ -134,9 +223,9 @@ export const simOrderActivationAddonsSchema = z.object({
export const simOrderActivationRequestSchema = z.object({ export const simOrderActivationRequestSchema = z.object({
planSku: z.string().min(1, "Plan SKU is required"), planSku: z.string().min(1, "Plan SKU is required"),
simType: z.enum(["eSIM", "Physical SIM"]), simType: simCardTypeSchema,
eid: z.string().min(15, "EID must be at least 15 characters").optional(), eid: z.string().min(15, "EID must be at least 15 characters").optional(),
activationType: z.enum(["Immediate", "Scheduled"]), activationType: simActivationTypeSchema,
scheduledAt: z.string().regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format").optional(), scheduledAt: z.string().regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format").optional(),
addons: simOrderActivationAddonsSchema.optional(), addons: simOrderActivationAddonsSchema.optional(),
mnp: simOrderActivationMnpSchema.optional(), mnp: simOrderActivationMnpSchema.optional(),
@ -202,3 +291,51 @@ export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>; export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>; export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
export type SimReissueRequest = z.infer<typeof simReissueRequestSchema>; export type SimReissueRequest = z.infer<typeof simReissueRequestSchema>;
export type SimConfigureFormData = z.infer<typeof simConfigureFormSchema>;
export type SimCardType = z.infer<typeof simCardTypeSchema>;
export type ActivationType = z.infer<typeof simActivationTypeSchema>;
export type MnpData = z.infer<typeof simMnpFormSchema>;
export interface SimConfigureFormToRequestOptions {
planSku: string;
msisdn: string;
monthlyAmountJpy: number;
oneTimeAmountJpy: number;
addons?: SimOrderActivationAddons;
}
export function simConfigureFormToRequest(
formData: SimConfigureFormData,
options: SimConfigureFormToRequestOptions
): SimOrderActivationRequest {
const scheduledAt =
formData.activationType === "Scheduled" ? formData.scheduledActivationDate : undefined;
const mnp =
formData.wantsMnp && formData.mnpData
? {
reserveNumber: formData.mnpData.reservationNumber,
reserveExpireDate: formData.mnpData.expiryDate,
account: formData.mnpData.mvnoAccountNumber ?? undefined,
firstnameKanji: formData.mnpData.portingFirstName ?? undefined,
lastnameKanji: formData.mnpData.portingLastName ?? undefined,
firstnameZenKana: formData.mnpData.portingFirstNameKatakana ?? undefined,
lastnameZenKana: formData.mnpData.portingLastNameKatakana ?? undefined,
gender: formData.mnpData.portingGender ?? undefined,
birthday: formData.mnpData.portingDateOfBirth ?? undefined,
}
: undefined;
return simOrderActivationRequestSchema.parse({
planSku: options.planSku,
simType: formData.simType,
eid: formData.simType === "eSIM" ? formData.eid?.trim() || undefined : undefined,
activationType: formData.activationType,
scheduledAt,
addons: options.addons,
mnp,
msisdn: options.msisdn,
oneTimeAmountJpy: options.oneTimeAmountJpy,
monthlyAmountJpy: options.monthlyAmountJpy,
});
}

View File

@ -1,49 +1,97 @@
/** /**
* Toolkit - Currency Formatting * Toolkit - Currency Formatting
* *
* Simple currency formatting. Currency code comes from user's WHMCS profile. * Our product currently operates in Japanese Yen only, but we keep a single
* Typically JPY for this application. * helper so the portal and BFF share the same formatting behaviour.
*
* The function still accepts legacy signatures (`formatCurrency(amount, "JPY", "¥")`)
* so existing call sites remain compatible.
*/ */
export type SupportedCurrency = "JPY" | "USD" | "EUR"; export type SupportedCurrency = "JPY";
type LegacyOptions = {
/**
* Optional locale override. Defaults to "ja-JP".
*/
locale?: string;
/**
* Set to false if you ever want to hide the symbol. Defaults to true.
*/
showSymbol?: boolean;
/**
* Optional custom symbol. Defaults to "¥".
*/
currencySymbol?: string;
};
const DEFAULT_CURRENCY: SupportedCurrency = "JPY";
const DEFAULT_SYMBOL = "¥";
const DEFAULT_LOCALE = "ja-JP";
export const getCurrencyLocale = (_currency?: string): string => DEFAULT_LOCALE;
const normalizeOptions = (
currencyOrOptions?: string | LegacyOptions,
symbolOrOptions?: string | LegacyOptions
) => {
const result: {
currency: string;
symbol: string;
locale: string;
showSymbol: boolean;
} = {
currency: DEFAULT_CURRENCY,
symbol: DEFAULT_SYMBOL,
locale: DEFAULT_LOCALE,
showSymbol: true,
};
const applyOptions = (opts?: LegacyOptions) => {
if (!opts) return;
if (opts.locale) result.locale = opts.locale;
if (typeof opts.showSymbol === "boolean") result.showSymbol = opts.showSymbol;
if (opts.currencySymbol) result.symbol = opts.currencySymbol;
};
if (typeof currencyOrOptions === "string") {
result.currency = currencyOrOptions;
} else {
applyOptions(currencyOrOptions);
}
if (typeof symbolOrOptions === "string") {
result.symbol = symbolOrOptions;
} else {
applyOptions(symbolOrOptions);
}
// Even if a different currency code is provided, we treat it like JPY for now.
const fractionDigits = result.currency.toUpperCase() === "JPY" ? 0 : 2;
return { ...result, fractionDigits };
};
/**
* Format a number as currency using WHMCS currency data
*
* @param amount - The numeric amount to format
* @param currencyCode - Currency code from WHMCS API (e.g., "JPY", "USD", "EUR")
* @param currencyPrefix - Currency symbol from WHMCS API (e.g., "¥", "$", "€")
*
* @example
* formatCurrency(1000, "JPY", "¥") // ¥1,000
* formatCurrency(1000, "USD", "$") // $1,000.00
* formatCurrency(1000, "EUR", "€") // €1,000.00
*/
export function formatCurrency( export function formatCurrency(
amount: number, amount: number,
currencyCode: string, currencyOrOptions?: string | LegacyOptions,
currencyPrefix: string symbolOrOptions?: string | LegacyOptions
): string { ): string {
// Determine fraction digits based on currency const { locale, symbol, showSymbol, fractionDigits } = normalizeOptions(
const fractionDigits = currencyCode === "JPY" ? 0 : 2; currencyOrOptions,
symbolOrOptions
// Format the number with appropriate decimal places );
const formattedAmount = amount.toLocaleString("en-US", {
const formatted = amount.toLocaleString(locale, {
minimumFractionDigits: fractionDigits, minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits,
}); });
// Add currency prefix return showSymbol ? `${symbol}${formatted}` : formatted;
return `${currencyPrefix}${formattedAmount}`;
} }
/**
* Parse a currency string to a number
*/
export function parseCurrency(value: string): number | null { export function parseCurrency(value: string): number | null {
// Remove currency symbols, commas, and whitespace
const cleaned = value.replace(/[¥$€,\s]/g, ""); const cleaned = value.replace(/[¥$€,\s]/g, "");
const parsed = Number.parseFloat(cleaned); const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null;
} }