From 939922a40ee19908fad8d327279a362ac0eb57f9 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 21 Oct 2025 11:44:06 +0900 Subject: [PATCH] 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. --- .../services/order-validator.service.ts | 5 +- .../subscriptions/sim-management.service.ts | 9 +- .../services/esim-management.service.ts | 16 +- .../services/sim-orchestrator.service.ts | 9 +- .../services/sim-plan.service.ts | 46 +- .../subscriptions/subscriptions.controller.ts | 16 +- .../subscriptions/subscriptions.service.ts | 55 +- .../billing/hooks/usePaymentRefresh.ts | 8 +- .../features/billing/views/PaymentMethods.tsx | 5 +- .../catalog/components/base/OrderSummary.tsx | 2 +- .../internet/InstallationOptions.tsx | 13 +- .../configure/hooks/useConfigureState.ts | 4 - .../catalog/components/sim/MnpForm.tsx | 38 +- .../catalog/hooks/useConfigureParams.ts | 50 +- .../features/catalog/hooks/useSimConfigure.ts | 141 ++++- .../features/checkout/hooks/useCheckout.ts | 149 ++++-- .../checkout/views/CheckoutContainer.tsx | 2 +- .../orders/services/orders.service.ts | 7 +- .../src/features/orders/views/OrderDetail.tsx | 6 +- .../components/SimDetailsCard.tsx | 497 ++++++------------ .../components/SimManagementSection.tsx | 8 +- .../subscriptions/containers/SimCancel.tsx | 2 +- .../subscriptions/hooks/useSubscriptions.ts | 22 +- .../subscriptions/views/SimCancel.tsx | 2 +- apps/portal/src/lib/api/index.ts | 2 +- apps/portal/src/lib/api/runtime/client.ts | 8 +- packages/domain/orders/helpers.ts | 71 +++ packages/domain/orders/index.ts | 5 + packages/domain/sim/index.ts | 5 + packages/domain/sim/schema.ts | 141 ++++- .../domain/toolkit/formatting/currency.ts | 110 ++-- 31 files changed, 868 insertions(+), 586 deletions(-) create mode 100644 packages/domain/orders/helpers.ts diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index a6fbc315..e8a3b5f4 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -6,7 +6,7 @@ import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/conn import { getErrorMessage } from "@bff/core/utils/error.util"; import { createOrderRequestSchema, - orderBusinessValidationSchema, + orderWithSkuValidationSchema, type CreateOrderRequest, type OrderBusinessValidation, } from "@customer-portal/domain/orders"; @@ -176,10 +176,11 @@ export class OrderValidator { // 1b. Business validation (ensures userId-specific constraints) let businessValidatedBody: OrderBusinessValidation; try { - businessValidatedBody = orderBusinessValidationSchema.parse({ + const skuValidatedBody = orderWithSkuValidationSchema.parse({ ...validatedBody, userId, }); + businessValidatedBody = skuValidatedBody; } catch (error) { if (error instanceof ZodError) { const issues = error.issues.map(issue => { diff --git a/apps/bff/src/modules/subscriptions/sim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management.service.ts index d9de8b2a..716a3cba 100644 --- a/apps/bff/src/modules/subscriptions/sim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management.service.ts @@ -8,6 +8,7 @@ import type { SimCancelRequest, SimTopUpHistoryRequest, SimFeaturesUpdateRequest, + SimReissueRequest, } from "@customer-portal/domain/sim"; import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface"; @@ -108,8 +109,12 @@ export class SimManagementService { /** * Reissue eSIM profile */ - async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise { - return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid); + async reissueEsimProfile( + userId: string, + subscriptionId: number, + request: SimReissueRequest + ): Promise { + return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request); } /** diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts index da1583d1..8a33e05a 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts @@ -4,6 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f import { SimValidationService } from "./sim-validation.service"; import { SimNotificationService } from "./sim-notification.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; +import type { SimReissueRequest } from "@customer-portal/domain/sim"; @Injectable() export class EsimManagementService { @@ -17,7 +18,11 @@ export class EsimManagementService { /** * Reissue eSIM profile */ - async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise { + async reissueEsimProfile( + userId: string, + subscriptionId: number, + request: SimReissueRequest + ): Promise { try { 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"); } + const newEid = request.newEid; + if (newEid) { - if (!/^\d{32}$/.test(newEid)) { - throw new BadRequestException("Invalid EID format. Expected 32 digits."); - } await this.freebitService.reissueEsimProfileEnhanced(account, newEid, { oldEid: simDetails.eid, planCode: simDetails.planCode, @@ -60,12 +64,12 @@ export class EsimManagementService { error: sanitizedError, userId, subscriptionId, - newEid: newEid || undefined, + newEid: request.newEid || undefined, }); await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", { userId, subscriptionId, - newEid: newEid || undefined, + newEid: request.newEid || undefined, error: sanitizedError, }); throw error; diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts index 78470a7a..e3f6121a 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts @@ -15,6 +15,7 @@ import type { SimCancelRequest, SimTopUpHistoryRequest, SimFeaturesUpdateRequest, + SimReissueRequest, } from "@customer-portal/domain/sim"; @Injectable() @@ -98,8 +99,12 @@ export class SimOrchestratorService { /** * Reissue eSIM profile */ - async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise { - return this.esimManagement.reissueEsimProfile(userId, subscriptionId, newEid); + async reissueEsimProfile( + userId: string, + subscriptionId: number, + request: SimReissueRequest + ): Promise { + return this.esimManagement.reissueEsimProfile(userId, subscriptionId, request); } /** diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts index 42330c51..ea45e71d 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts @@ -23,6 +23,9 @@ export class SimPlanService { subscriptionId: number, request: SimPlanChangeRequest ): Promise<{ ipv4?: string; ipv6?: string }> { + const assignGlobalIp = request.assignGlobalIp ?? false; + let scheduledAt: string | undefined; + try { const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); @@ -31,27 +34,35 @@ export class SimPlanService { throw new BadRequestException("Invalid plan code"); } - // Automatically set to 1st of next month - const nextMonth = new Date(); - nextMonth.setMonth(nextMonth.getMonth() + 1); - nextMonth.setDate(1); // Set to 1st of the month + scheduledAt = request.scheduledAt; + if (scheduledAt) { + if (!/^\d{8}$/.test(scheduledAt)) { + 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 - 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}`, { + this.logger.log("Submitting SIM plan change request", { userId, subscriptionId, account, newPlanCode: request.newPlanCode, + scheduledAt, + assignGlobalIp, + scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default", }); const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, { - assignGlobalIp: false, // Default to no global IP - scheduledAt: scheduledAt, + assignGlobalIp, + scheduledAt: scheduledAt!, }); this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { @@ -59,8 +70,8 @@ export class SimPlanService { subscriptionId, account, newPlanCode: request.newPlanCode, - scheduledAt: scheduledAt, - assignGlobalIp: false, + scheduledAt, + assignGlobalIp, }); await this.simNotification.notifySimAction("Change Plan", "SUCCESS", { @@ -69,6 +80,7 @@ export class SimPlanService { account, newPlanCode: request.newPlanCode, scheduledAt, + assignGlobalIp, }); return result; @@ -79,11 +91,15 @@ export class SimPlanService { userId, subscriptionId, newPlanCode: request.newPlanCode, + assignGlobalIp, + scheduledAt, }); await this.simNotification.notifySimAction("Change Plan", "ERROR", { userId, subscriptionId, newPlanCode: request.newPlanCode, + assignGlobalIp, + scheduledAt, error: sanitizedError, }); throw error; diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index a78e37d7..de427f27 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -32,10 +32,12 @@ import { simChangePlanRequestSchema, simCancelRequestSchema, simFeaturesRequestSchema, + simReissueRequestSchema, type SimTopupRequest, type SimChangePlanRequest, type SimCancelRequest, type SimFeaturesRequest, + type SimReissueRequest, } from "@customer-portal/domain/sim"; import { ZodValidationPipe } from "@bff/core/validation"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; @@ -52,12 +54,9 @@ export class SubscriptionsController { async getSubscriptions( @Request() req: RequestWithUser, @Query() query: SubscriptionQuery - ): Promise { - if (query.status) { - return this.subscriptionsService.getSubscriptionsByStatus(req.user.id, query.status); - } - - return this.subscriptionsService.getSubscriptions(req.user.id); + ): Promise { + const { status } = query; + return this.subscriptionsService.getSubscriptions(req.user.id, { status }); } @Get("active") @@ -185,12 +184,13 @@ export class SubscriptionsController { } @Post(":id/sim/reissue-esim") + @UsePipes(new ZodValidationPipe(simReissueRequestSchema)) async reissueEsimProfile( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: { newEid?: string } = {} + @Body() body: SimReissueRequest ): Promise { - 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" }; } diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 60e0fe9f..e9312a2f 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -1,18 +1,22 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; 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 { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { Logger } from "nestjs-pino"; -import { z } from "zod"; -import { subscriptionSchema } from "@customer-portal/domain/subscriptions"; import type { Providers } from "@customer-portal/domain/subscriptions"; type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; export interface GetSubscriptionsOptions { - status?: string; + status?: SubscriptionStatus; } @Injectable() @@ -44,25 +48,18 @@ export class SubscriptionsService { { status } ); - const parsed = z - .object({ - subscriptions: z.array(subscriptionSchema), - totalCount: z.number(), - }) - .safeParse(subscriptionList); + const parsed = subscriptionListSchema.parse(subscriptionList); - if (!parsed.success) { - throw new Error(parsed.error.message); + let subscriptions = parsed.subscriptions; + if (status) { + const normalizedStatus = subscriptionStatusSchema.parse(status); + subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus); } - const filtered = status - ? parsed.data.subscriptions.filter(sub => sub.status.toLowerCase() === status.toLowerCase()) - : parsed.data.subscriptions; - - return { - subscriptions: filtered, - totalCount: filtered.length, - } satisfies SubscriptionList; + return subscriptionListSchema.parse({ + subscriptions, + totalCount: subscriptions.length, + }); } catch (error) { this.logger.error(`Failed to get subscriptions for user ${userId}`, { error: getErrorMessage(error), @@ -141,22 +138,10 @@ export class SubscriptionsService { /** * Get subscriptions by status */ - async getSubscriptionsByStatus(userId: string, status: string): Promise { + async getSubscriptionsByStatus(userId: string, status: SubscriptionStatus): Promise { try { - // Validate status - const validStatuses = [ - "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 }); + const normalizedStatus = subscriptionStatusSchema.parse(status); + const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus }); return subscriptionList.subscriptions; } catch (error) { this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, { diff --git a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts index 0173cfdf..f1ee68c8 100644 --- a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts +++ b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts @@ -11,11 +11,14 @@ interface UsePaymentRefreshOptions { refetch: () => Promise<{ data: PaymentMethodList | undefined }>; // When true, attaches focus/visibility listeners to refresh automatically attachFocusListeners?: boolean; + // Optional custom detector for whether payment methods exist + hasMethods?: (data?: PaymentMethodList | undefined) => boolean; } export function usePaymentRefresh({ refetch, attachFocusListeners = false, + hasMethods, }: UsePaymentRefreshOptions) { const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({ visible: false, @@ -35,7 +38,10 @@ export function usePaymentRefresh({ const result = await refetch(); const parsed = paymentMethodListSchema.safeParse(result.data ?? null); 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({ visible: true, text: has ? "Payment methods updated" : "No payment method found yet", diff --git a/apps/portal/src/features/billing/views/PaymentMethods.tsx b/apps/portal/src/features/billing/views/PaymentMethods.tsx index ef8f5433..59640a73 100644 --- a/apps/portal/src/features/billing/views/PaymentMethods.tsx +++ b/apps/portal/src/features/billing/views/PaymentMethods.tsx @@ -42,7 +42,8 @@ export function PaymentMethodsContainer() { const result = await paymentMethodsQuery.refetch(); 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, }); @@ -245,4 +246,4 @@ export function PaymentMethodsContainer() { ); } -export default PaymentMethodsContainer; \ No newline at end of file +export default PaymentMethodsContainer; diff --git a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx index afeeb5b8..5e9b325e 100644 --- a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx @@ -185,7 +185,7 @@ export function OrderSummary({
{String(fee.name)} - ¥{getOneTimePrice(fee).toLocaleString()} one-time + ¥{(fee.oneTimePrice ?? fee.unitPrice ?? 0).toLocaleString()} one-time
))} diff --git a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx b/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx index cb420a5b..97312b8d 100644 --- a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx +++ b/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx @@ -53,8 +53,17 @@ function getPriceLabel(installation: InternetInstallationCatalogItem): string { if (!priceInfo) { return "Price not available"; } - const suffix = priceInfo.billingCycle === "Monthly" ? "/month" : " one-time"; - return `¥${priceInfo.amount.toLocaleString()}${suffix}`; + const billingCycle = installation.billingCycle?.toLowerCase(); + 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({ diff --git a/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts b/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts index 47e51cc1..77d57d92 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts +++ b/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts @@ -169,7 +169,3 @@ function calculateOneTimeTotal( return total; } - -type InstallationTerm = NonNullable< - NonNullable["installationTerm"] ->; diff --git a/apps/portal/src/features/catalog/components/sim/MnpForm.tsx b/apps/portal/src/features/catalog/components/sim/MnpForm.tsx index c976fc5b..3d8a6eae 100644 --- a/apps/portal/src/features/catalog/components/sim/MnpForm.tsx +++ b/apps/portal/src/features/catalog/components/sim/MnpForm.tsx @@ -1,17 +1,5 @@ import React from "react"; - -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; -} +import type { MnpData } from "@customer-portal/domain/sim"; interface MnpFormProps { wantsMnp: boolean; @@ -77,7 +65,7 @@ export function MnpForm({ 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" placeholder="10-digit reservation number" @@ -95,7 +83,7 @@ export function MnpForm({ 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" /> @@ -112,7 +100,7 @@ export function MnpForm({ 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" placeholder="090-1234-5678" @@ -133,7 +121,7 @@ export function MnpForm({ 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" placeholder="Your current carrier account number" @@ -151,7 +139,7 @@ export function MnpForm({ 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" placeholder="Tanaka" @@ -172,7 +160,7 @@ export function MnpForm({ 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" placeholder="Taro" @@ -193,7 +181,7 @@ export function MnpForm({ 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" placeholder="タナカ" @@ -214,7 +202,7 @@ export function MnpForm({ 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" placeholder="タロウ" @@ -234,10 +222,8 @@ export function MnpForm({ 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" /> diff --git a/apps/portal/src/features/catalog/hooks/useConfigureParams.ts b/apps/portal/src/features/catalog/hooks/useConfigureParams.ts index ed4ab726..0c664b0d 100644 --- a/apps/portal/src/features/catalog/hooks/useConfigureParams.ts +++ b/apps/portal/src/features/catalog/hooks/useConfigureParams.ts @@ -31,24 +31,48 @@ export function useSimConfigureParams() { activationTypeParam === "Immediate" || activationTypeParam === "Scheduled" ? activationTypeParam : null; - const scheduledAt = params.get("scheduledAt"); + const scheduledAt = params.get("scheduledAt") ?? params.get("scheduledDate"); 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 const mnp = { - reservationNumber: params.get("reservationNumber") || undefined, - expiryDate: params.get("expiryDate") || undefined, - phoneNumber: params.get("phoneNumber") || undefined, - mvnoAccountNumber: params.get("mvnoAccountNumber") || undefined, - 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) || + reservationNumber: + params.get("mnpNumber") ?? + params.get("reservationNumber") ?? + params.get("mnp_reservationNumber") ?? 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; return { diff --git a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts index 2bf34e73..16205520 100644 --- a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts @@ -6,12 +6,12 @@ import { useSimCatalog, useSimPlan, useSimConfigureParams } from "."; import { useZodForm } from "@customer-portal/validation"; import { simConfigureFormSchema, - simConfigureFormToRequest, type SimConfigureFormData, - type SimType, + type SimCardType, type ActivationType, 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"; export type UseSimConfigureResult = { @@ -31,8 +31,8 @@ export type UseSimConfigureResult = { validate: () => boolean; // Convenience getters for specific fields - simType: SimType; - setSimType: (value: SimType) => void; + simType: SimCardType; + setSimType: (value: SimCardType) => void; eid: string; setEid: (value: string) => void; selectedAddons: string[]; @@ -63,6 +63,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { const searchParams = useSearchParams(); const { data: simData, isLoading: simLoading } = useSimCatalog(); const { plan: selectedPlan } = useSimPlan(planId); + const configureParams = useSimConfigureParams(); // Step orchestration state const [currentStep, setCurrentStep] = useState(0); @@ -93,13 +94,10 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { const { values, errors, setValue, validate } = useZodForm({ schema: simConfigureFormSchema, initialValues, - onSubmit: data => { - simConfigureFormToRequest(data); - }, }); // 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 setSelectedAddons = useCallback( (value: SimConfigureFormData["selectedAddons"]) => setValue("selectedAddons", value), @@ -124,16 +122,70 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { if (mounted) { // 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 = - (searchParams.get("activationType") as ActivationType) || "Immediate"; + (configureParams.activationType as ActivationType | null) ?? + (searchParams.get("activationType") as ActivationType) ?? + "Immediate"; setSimType(initialSimType); - setEid(searchParams.get("eid") || ""); - setSelectedAddons(searchParams.get("addons")?.split(",").filter(Boolean) || []); + setEid(configureParams.eid ?? searchParams.get("eid") ?? ""); + const addonSkuSet = new Set(); + configureParams.addonSkus.forEach(sku => addonSkuSet.add(sku)); + searchParams + .get("addons") + ?.split(",") + .filter(Boolean) + .forEach(sku => addonSkuSet.add(sku)); + setSelectedAddons(Array.from(addonSkuSet)); setActivationType(initialActivationType); - setScheduledActivationDate(searchParams.get("scheduledDate") || ""); - setWantsMnp(searchParams.get("wantsMnp") === "true"); + const scheduledAt = + 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, simData, - selectedPlan, searchParams, + configureParams, setSimType, setEid, setSelectedAddons, setActivationType, setScheduledActivationDate, setWantsMnp, + setMnpData, ]); // Step transition handler (memoized) @@ -227,15 +280,57 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { params.set("activationType", values.activationType); if (values.scheduledActivationDate) { params.set("scheduledDate", values.scheduledActivationDate); + params.set( + "scheduledAt", + values.scheduledActivationDate.replace(/-/g, "") + ); + } else { + params.delete("scheduledDate"); + params.delete("scheduledAt"); } - if (values.wantsMnp) { - params.set("wantsMnp", "true"); - if (values.mnpData) { - Object.entries(values.mnpData).forEach(([key, value]) => { - if (value) params.set(`mnp_${key}`, value.toString()); - }); - } + const simConfig = buildSimOrderConfigurations(values); + params.set("simConfig", JSON.stringify(simConfig)); + + if (simConfig.scheduledAt && !values.scheduledActivationDate) { + 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)); } } diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index 1628a1ef..80119a42 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -10,7 +10,12 @@ import type { CatalogProductBase } from "@customer-portal/domain/catalog"; import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit"; import type { AsyncState } from "@customer-portal/domain/toolkit"; 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 import type { Address } from "@customer-portal/domain/customer"; @@ -84,6 +89,25 @@ export function useCheckout() { return obj; }, [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(() => { let mounted = true; @@ -226,20 +250,20 @@ export function useCheckout() { if (!mounted) return; const totals = calculateTotals(items); + const configuration = + orderType === ORDER_TYPE.SIM && simConfig ? simConfig : ({} as OrderConfigurations); setCheckoutState( createSuccessState({ items, totals, - configuration: {} as OrderConfigurations, + configuration, }) ); } catch (error) { if (mounted) { - setCheckoutState( - createErrorState( - error instanceof Error ? error.message : "Failed to load checkout data" - ) - ); + const reason = + error instanceof Error ? error.message : "Failed to load checkout data"; + setCheckoutState(createErrorState(new Error(reason))); } } })(); @@ -247,7 +271,7 @@ export function useCheckout() { return () => { mounted = false; }; - }, [orderType, params, selections]); + }, [orderType, params, selections, simConfig]); const handleSubmitOrder = useCallback(async () => { try { @@ -266,59 +290,90 @@ export function useCheckout() { throw new Error("No products selected for order. Please go back and select products."); } - const configurations: OrderConfigurations = { - ...(selections.accessMode ? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] } : {}), - ...(selections.activationType - ? { activationType: selections.activationType as OrderConfigurations["activationType"] } - : {}), - ...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}), - ...(selections.simType ? { simType: selections.simType as OrderConfigurations["simType"] } : {}), - ...(selections.eid ? { eid: selections.eid } : {}), - ...(selections.isMnp ? { isMnp: selections.isMnp } : {}), - ...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}), - ...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}), - ...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}), - ...(selections.mvnoAccountNumber ? { mvnoAccountNumber: selections.mvnoAccountNumber } : {}), - ...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}), - ...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}), - ...(selections.portingLastNameKatakana - ? { portingLastNameKatakana: selections.portingLastNameKatakana } - : {}), - ...(selections.portingFirstNameKatakana - ? { portingFirstNameKatakana: selections.portingFirstNameKatakana } - : {}), - ...(selections.portingGender - ? { - portingGender: selections.portingGender as OrderConfigurations["portingGender"], - } - : {}), - ...(selections.portingDateOfBirth ? { portingDateOfBirth: selections.portingDateOfBirth } : {}), - }; + let configurationAccumulator: Partial = {}; + + if (orderType === ORDER_TYPE.SIM) { + if (simConfig) { + configurationAccumulator = { ...simConfig }; + } else { + configurationAccumulator = { + ...(selections.simType + ? { simType: selections.simType as OrderConfigurations["simType"] } + : {}), + ...(selections.activationType + ? { activationType: selections.activationType as OrderConfigurations["activationType"] } + : {}), + ...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}), + ...(selections.eid ? { eid: selections.eid } : {}), + ...(selections.isMnp ? { isMnp: selections.isMnp } : {}), + ...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}), + ...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}), + ...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}), + ...(selections.mvnoAccountNumber + ? { mvnoAccountNumber: selections.mvnoAccountNumber } + : {}), + ...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}), + ...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}), + ...(selections.portingLastNameKatakana + ? { portingLastNameKatakana: selections.portingLastNameKatakana } + : {}), + ...(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) { - configurations.address = { - street: confirmedAddress.street ?? undefined, - streetLine2: confirmedAddress.streetLine2 ?? undefined, + configurationAccumulator.address = { + street: confirmedAddress.address1 ?? undefined, + streetLine2: confirmedAddress.address2 ?? undefined, city: confirmedAddress.city ?? undefined, state: confirmedAddress.state ?? undefined, - postalCode: confirmedAddress.postalCode ?? undefined, + postalCode: confirmedAddress.postcode ?? undefined, country: confirmedAddress.country ?? undefined, }; } + const hasConfiguration = Object.keys(configurationAccumulator).length > 0; + const configurations = hasConfiguration + ? orderConfigurationsSchema.parse(configurationAccumulator) + : undefined; + const orderData = { orderType, skus: uniqueSkus, - ...(Object.keys(configurations).length > 0 && { configurations }), + ...(configurations ? { configurations } : {}), }; - if (orderType === "SIM") { - if (!selections.eid && selections.simType === "eSIM") { + if (orderType === ORDER_TYPE.SIM) { + if (!configurations) { + throw new Error("SIM configuration is incomplete. Please restart the SIM configuration flow."); + } + if (configurations?.simType === "eSIM" && !configurations.eid) { throw new Error( "EID is required for eSIM activation. Please go back and provide your EID." ); } - if (!selections.phoneNumber && !selections.mnpPhone) { + if (!configurations?.mnpPhone) { throw new Error( "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`); } catch (error) { let errorMessage = "Order submission failed"; if (error instanceof Error) errorMessage = error.message; - setCheckoutState(createErrorState(errorMessage)); + setCheckoutState(createErrorState(new Error(errorMessage))); } finally { setSubmitting(false); } - }, [checkoutState, confirmedAddress, orderType, selections, router]); + }, [checkoutState, confirmedAddress, orderType, selections, router, simConfig, activeSubs]); const confirmAddress = useCallback((address?: Address) => { setAddressConfirmed(true); diff --git a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx index 68d4cf88..34b128a7 100644 --- a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx @@ -55,7 +55,7 @@ export function CheckoutContainer() {
- {checkoutState.error} + {checkoutState.error.message} diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index f10db612..13e0a8f7 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -1,6 +1,5 @@ import { apiClient } from "@/lib/api"; import { - createOrderRequest, orderDetailsSchema, orderSummarySchema, type CreateOrderRequest, @@ -13,11 +12,11 @@ import { } from "@/lib/api/response-helpers"; async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> { - const body = createOrderRequest({ + const body: CreateOrderRequest = { orderType: payload.orderType, skus: payload.skus, - configurations: payload.configurations ?? undefined, - }); + ...(payload.configurations ? { configurations: payload.configurations } : {}), + }; const response = await apiClient.POST("/api/orders", { body }); const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>( response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }> diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index 1c845e05..415beeb6 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -55,7 +55,7 @@ export function OrderDetailContainer() { ? deriveOrderStatusDescriptor({ status: data.status, activationStatus: data.activationStatus, - scheduledAt: data.scheduledAt, + scheduledAt: data.activationScheduledAt, }) : null; @@ -68,7 +68,7 @@ export function OrderDetailContainer() { const totals = calculateOrderTotals( data?.items?.map(item => ({ totalPrice: item.totalPrice, - billingCycle: item.product?.billingCycle, + billingCycle: item.billingCycle, })), data?.totalAmount ); @@ -176,7 +176,7 @@ export function OrderDetailContainer() { {data.items.map(item => { const productName = item.product?.name ?? "Product"; const sku = item.product?.sku ?? "N/A"; - const billingCycle = item.product?.billingCycle ?? ""; + const billingCycle = item.billingCycle ?? ""; return (
= { + active: , + suspended: , + cancelled: , + pending: , +}; + +const statusBadgeClass: Record = { + 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; +}) => ( +
+ {label} + + {enabled ? "Enabled" : "Disabled"} + +
+); + +const LoadingCard = ({ embedded }: { embedded: boolean }) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +const ErrorCard = ({ embedded, message }: { embedded: boolean; message: string }) => ( +
+
{message}
+
+); + export function SimDetailsCard({ simDetails, - isLoading, - error, + isLoading = false, + error = null, embedded = false, showFeaturesSummary = true, }: 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 ; - case "suspended": - return ; - case "cancelled": - return ; - case "pending": - return ; - default: - return ; - } - }; - - 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) { - const Skeleton = ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); - return Skeleton; + return ; } if (error) { - return ( -
-
-
- -
-

Error Loading SIM Details

-

{error}

-
-
- ); + return ; } - // Specialized, minimal eSIM details view - if (simDetails.simType === "esim") { - return ( -
- {/* Header */} -
-
-
-
- -
-
-

eSIM Details

-

- Current Plan: {formatPlan(simDetails.planCode)} -

-
-
- - {simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)} - -
-
- -
-
-
-
-

- - SIM Information -

-
-
- -

{simDetails.msisdn}

-
-
-
- -
- -

- {formatQuota(simDetails.remainingQuotaMb)} -

-
-
- - {showFeaturesSummary && ( -
-

- - Service Features -

-
-
- Voice Mail (¥300/month) - - {voiceMailEnabled ? "Enabled" : "Disabled"} - -
-
- Call Waiting (¥300/month) - - {callWaitingEnabled ? "Enabled" : "Disabled"} - -
-
- International Roaming - - {internationalRoamingEnabled ? "Enabled" : "Disabled"} - -
-
- 4G/5G - - {simDetails.networkType || "5G"} - -
-
-
- )} -
-
-
- ); - } + const planName = simDetails.planName || formatPlanShort(simDetails.planCode) || "SIM Plan"; + const normalizedStatus = simDetails.status?.toLowerCase() ?? "unknown"; + const statusIcon = statusIconMap[normalizedStatus] ?? ( + + ); + const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800"; + const containerClasses = embedded + ? "" + : "bg-white shadow-lg rounded-xl border border-gray-100"; return ( -
- {/* Header */} -
-
-
-
- -
-
-

Physical SIM Details

-

- {formatPlan(simDetails.planCode)} • {`${sizeLabel ?? "Unknown"} SIM`} -

-
+
+
+
+
+
-
- {getStatusIcon(simDetails.status)} - - {simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)} - +
+

{planName}

+

Account #{simDetails.account}

+ + {statusIcon} + {simDetails.status} +
- {/* Content */} -
-
- {/* SIM Information */} -
-

+
+
+
+

SIM Information

-
-
- -

{simDetails.msisdn}

+
+
+
Phone Number
+
{simDetails.msisdn}
+
+
+
SIM Type
+
{simDetails.simType}
+
+
+
ICCID
+
{simDetails.iccid}
- - {simDetails.simType === "physical" && ( -
- -

{simDetails.iccid}

-
- )} - {simDetails.eid && ( -
- -

{simDetails.eid}

+
+
EID
+
{simDetails.eid}
)} - - {simDetails.imsi && ( -
- -

{simDetails.imsi}

-
- )} - - {simDetails.startDate && ( -
- -

{formatDate(simDetails.startDate)}

-
- )} -
-
- - {/* Service Features */} - {showFeaturesSummary && ( -
-

- Service Features -

-
-
- -

- {formatQuota(simDetails.remainingQuotaMb)} -

-
- -
-
- - - Voice {hasVoice ? "Enabled" : "Disabled"} - -
-
- - - SMS {hasSms ? "Enabled" : "Disabled"} - -
-
- - {(simDetails.ipv4 || simDetails.ipv6) && ( -
- -
- {ipv4Address && ( -

IPv4: {ipv4Address}

- )} - {ipv6Address && ( -

IPv6: {ipv6Address}

- )} - {!ipv4Address && !ipv6Address && ( -

No IP assigned

- )} -
-
- )} +
+
Network Type
+
{simDetails.networkType}
-
- )} + +
+ +
+

+ Data Remaining +

+

+ {formatQuota(simDetails.remainingQuotaMb)} +

+

Remaining allowance in current cycle

+
+ +
+

+ Activation Timeline +

+
+
+
Activated
+
{formatDate(simDetails.activatedAt)}
+
+
+
Expires
+
{formatDate(simDetails.expiresAt)}
+
+
+
- {/* Pending Operations */} - {simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && ( -
-

- Pending Operations + {showFeaturesSummary && ( +
+

+ Service Features

-
- {simDetails.pendingOperations.map((operation, index) => ( -
- - - {operation.operation} scheduled for {formatDate(operation.scheduledDate)} - -
- ))} -
-

+ + + + )}

); } + +export type { SimDetails }; diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 3726fc82..ed6b18c3 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -6,11 +6,12 @@ import { ExclamationTriangleIcon, ArrowPathIcon, } from "@heroicons/react/24/outline"; -import { SimDetailsCard, type SimDetails } from "./SimDetailsCard"; +import { SimDetailsCard } from "./SimDetailsCard"; import { DataUsageChart, type SimUsage } from "./DataUsageChart"; import { SimActions } from "./SimActions"; import { apiClient } from "@/lib/api"; import { SimFeatureToggles } from "./SimFeatureToggles"; +import type { SimDetails } from "@customer-portal/domain/sim"; interface SimManagementSectionProps { subscriptionId: number; @@ -137,6 +138,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro return null; } + const actionSimType: "esim" | "physical" = + simInfo.details.simType.toLowerCase() === "esim" ? "esim" : "physical"; + return (
{/* SIM Details and Usage - Main Content */} @@ -146,7 +150,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
- +