Enhance TypeScript configurations and improve error handling in services

- Updated tsconfig.json to include test files for better type checking.
- Refined type annotations in FreebitAuthService for improved clarity and type safety.
- Enhanced currency index extraction logic in WhmcsCurrencyService for better type inference.
- Improved utility functions in whmcs-client.utils.ts to handle various value types more robustly.
- Simplified user creation logic in WhmcsLinkWorkflowService by removing unnecessary type conversions.
- Updated RequestWithCookies type in auth.controller.ts and global-auth.guard.ts to allow optional cookies.
- Refactored OrderFulfillmentOrchestrator to utilize a more specific mapping result type for better type safety.
- Added error logging enhancements in UsersService for improved traceability.
- Updated catalog contract tests to ensure response validation aligns with new schemas.
- Improved InvoiceTable component to handle payment and download actions more cleanly.
This commit is contained in:
barsa 2025-10-22 10:23:56 +09:00
parent e42d474048
commit 6567bc5907
17 changed files with 388 additions and 196 deletions

View File

@ -69,7 +69,7 @@ export class FreebitAuthService {
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
}
const request = FreebitProvider.schemas.auth.parse({
const request: FreebitAuthRequest = FreebitProvider.schemas.auth.parse({
oemId: this.config.oemId,
oemKey: this.config.oemKey,
});
@ -84,8 +84,9 @@ export class FreebitAuthService {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = (await response.json()) as unknown;
const data = FreebitProvider.mapper.transformFreebitAuthResponse(json);
const json: unknown = await response.json();
const data: FreebitAuthResponse =
FreebitProvider.mapper.transformFreebitAuthResponse(json);
if (data.resultCode !== "100" || !data.authKey) {
throw new FreebitError(

View File

@ -165,10 +165,12 @@ export class WhmcsCurrencyService implements OnModuleInit {
);
// Extract currency indices
const currencyIndices = currencyKeys.map(key => {
const match = key.match(/currencies\[currency\]\[(\d+)\]\[id\]/);
return match ? parseInt(match[1]) : null;
}).filter(index => index !== null) as number[];
const currencyIndices = currencyKeys
.map(key => {
const match = key.match(/currencies\[currency\]\[(\d+)\]\[id\]/);
return match ? parseInt(match[1], 10) : null;
})
.filter((index): index is number => index !== null);
// Build currency objects from the flat response
for (const index of currencyIndices) {

View File

@ -31,7 +31,10 @@ const toNumber = (value: unknown): number | null => {
const toOptionalString = (value: unknown): string | undefined => {
if (value === undefined || value === null) return undefined;
if (typeof value === "string") return value;
return String(value);
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
};
const toNullableBoolean = (value: unknown): boolean | null | undefined => {

View File

@ -138,16 +138,14 @@ export class WhmcsLinkWorkflowService {
const createdUser = await this.usersService.create({
email,
passwordHash: null,
firstName: String(clientDetails.firstname ?? ""),
lastName: String(clientDetails.lastname ?? ""),
company: String(clientDetails.companyname ?? ""), // Raw WHMCS field name
firstName: clientDetails.firstname ?? "",
lastName: clientDetails.lastname ?? "",
company: clientDetails.companyname ?? "", // Raw WHMCS field name
phone:
String(
clientDetails.phonenumberformatted ??
clientDetails.phonenumber ??
clientDetails.telephoneNumber ??
""
), // Raw WHMCS field names
clientDetails.phonenumberformatted ??
clientDetails.phonenumber ??
clientDetails.telephoneNumber ??
"",
emailVerified: true,
});

View File

@ -47,8 +47,9 @@ import {
type AuthTokens,
} from "@customer-portal/domain/auth";
type RequestWithCookies = Request & {
cookies: Record<string, any>;
type CookieValue = string | undefined;
type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, CookieValue>;
};
const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => {

View File

@ -15,8 +15,9 @@ import { TokenBlacklistService } from "../../../infra/token/token-blacklist.serv
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator";
import { getErrorMessage } from "@bff/core/utils/error.util";
type RequestWithCookies = Request & {
cookies: Record<string, any>;
type CookieValue = string | undefined;
type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, CookieValue>;
};
type RequestWithRoute = RequestWithCookies & {
method: string;

View File

@ -22,14 +22,7 @@ import {
Providers as OrderProviders,
} from "@customer-portal/domain/orders";
export interface OrderItemMappingResult {
whmcsItems: any[];
summary: {
totalItems: number;
serviceItems: number;
activationItems: number;
};
}
type WhmcsOrderItemMappingResult = ReturnType<typeof OrderProviders.Whmcs.mapOrderToWhmcsItems>;
export interface OrderFulfillmentStep {
step: string;
@ -44,7 +37,7 @@ export interface OrderFulfillmentContext {
idempotencyKey: string;
validation: OrderFulfillmentValidationResult | null;
orderDetails?: OrderDetails;
mappingResult?: OrderItemMappingResult;
mappingResult?: WhmcsOrderItemMappingResult;
whmcsResult?: WhmcsOrderResult;
steps: OrderFulfillmentStep[];
}
@ -134,7 +127,7 @@ export class OrderFulfillmentOrchestrator {
}
// Step 3: Execute the main fulfillment workflow as a distributed transaction
let mappingResult: OrderItemMappingResult | undefined;
let mappingResult: WhmcsOrderItemMappingResult | undefined;
let whmcsCreateResult: { orderId: number } | undefined;
let whmcsAcceptResult: WhmcsOrderResult | undefined;

View File

@ -60,9 +60,13 @@ export class SimPlanService {
scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default",
});
if (!scheduledAt) {
throw new BadRequestException("Failed to determine schedule date for plan change");
}
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
assignGlobalIp,
scheduledAt: scheduledAt!,
scheduledAt,
});
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {

View File

@ -328,7 +328,10 @@ export class UsersService {
const profile = await this.getProfile(userId);
currency = profile.currency_code || currency;
} catch (error) {
this.logger.warn("Could not fetch currency from profile", { userId });
this.logger.warn("Could not fetch currency from profile", {
userId,
error: getErrorMessage(error),
});
}
const summary: DashboardSummary = {
@ -529,14 +532,15 @@ export class UsersService {
let currency = "JPY"; // Default
try {
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
if (client && typeof client === 'object' && 'currency_code' in client) {
const currency_code = (client as any).currency_code;
if (currency_code) {
currency = currency_code;
}
const currencyCode = client.currency_code ?? client.raw.currency_code ?? null;
if (currencyCode) {
currency = currencyCode;
}
} catch (error) {
this.logger.warn("Could not fetch currency from WHMCS client", { userId });
this.logger.warn("Could not fetch currency from WHMCS client", {
userId,
error: getErrorMessage(error),
});
}
const summary: DashboardSummary = {

View File

@ -1,10 +1,23 @@
/// <reference types="jest" />
import request from "supertest";
import { INestApplication } from "@nestjs/common";
import type { INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import type { Server } from "node:http";
import { AppModule } from "../src/app.module";
import { parseInternetCatalog } from "@customer-portal/domain/catalog";
import {
parseInternetCatalog,
internetCatalogResponseSchema,
} from "@customer-portal/domain/catalog";
import { apiSuccessResponseSchema } from "@customer-portal/domain/common/schema";
const internetCatalogApiResponseSchema = apiSuccessResponseSchema(internetCatalogResponseSchema);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
const isHttpServer = (value: unknown): value is Server =>
isRecord(value) && typeof value.listen === "function" && typeof value.close === "function";
describe("Catalog contract", () => {
let app: INestApplication;
@ -23,11 +36,15 @@ describe("Catalog contract", () => {
});
it("should return internet catalog matching domain schema", async () => {
const response = await request(app.getHttpServer()).get("/catalog/internet/plans");
const serverCandidate: unknown = app.getHttpServer();
if (!isHttpServer(serverCandidate)) {
throw new Error("Expected Nest application to expose an HTTP server");
}
const response = await request(serverCandidate).get("/catalog/internet/plans");
expect(response.status).toBe(200);
expect(() => parseInternetCatalog(response.body.data)).not.toThrow();
const payload = internetCatalogApiResponseSchema.parse(response.body);
expect(() => parseInternetCatalog(payload.data)).not.toThrow();
});
});

View File

@ -27,6 +27,6 @@
"module": "commonjs"
}
},
"include": ["src/**/*", "scripts/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
"include": ["src/**/*", "scripts/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -229,7 +229,9 @@ export function InvoiceTable({
<Button
size="sm"
variant="default"
onClick={(e) => handlePayment(invoice, e)}
onClick={event => {
void handlePayment(invoice, event);
}}
loading={isPaymentLoading}
className="text-xs font-medium shadow-sm"
>
@ -241,7 +243,9 @@ export function InvoiceTable({
<Button
size="sm"
variant="outline"
onClick={(e) => handleDownload(invoice, e)}
onClick={event => {
void handleDownload(invoice, event);
}}
loading={isDownloadLoading}
leftIcon={!isDownloadLoading ? <ArrowDownTrayIcon className="h-4 w-4" /> : undefined}
className="text-xs font-medium border-gray-300 hover:border-gray-400 hover:bg-gray-50"

View File

@ -12,7 +12,7 @@ interface UsePaymentRefreshOptions {
// When true, attaches focus/visibility listeners to refresh automatically
attachFocusListeners?: boolean;
// Optional custom detector for whether payment methods exist
hasMethods?: (data?: PaymentMethodList | undefined) => boolean;
hasMethods?: (data?: PaymentMethodList) => boolean;
}
export function usePaymentRefresh({

View File

@ -1,85 +1,121 @@
"use client";
import { useSearchParams } from "next/navigation";
import type { SimCardType, ActivationType, MnpData } from "@customer-portal/domain/sim";
export type AccessMode = "IPoE-BYOR" | "PPPoE";
const parseSimCardType = (value: string | null): SimCardType | null => {
if (value === "eSIM" || value === "Physical SIM") {
return value;
}
return null;
};
const parseActivationType = (value: string | null): ActivationType | null => {
if (value === "Immediate" || value === "Scheduled") {
return value;
}
return null;
};
const parsePortingGender = (
value: string | null
): MnpData["portingGender"] | undefined => {
if (value === "Male" || value === "Female" || value === "Corporate/Other") {
return value;
}
return undefined;
};
const coalesce = <T>(...values: Array<T | null | undefined>): T | undefined => {
for (const candidate of values) {
if (candidate !== null && candidate !== undefined) {
return candidate;
}
}
return undefined;
};
export function useInternetConfigureParams() {
const params = useSearchParams();
const accessModeParam = params.get("accessMode");
const accessMode =
const accessMode: AccessMode | null =
accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE"
? (accessModeParam as AccessMode)
? accessModeParam
: null;
const installationSku = params.get("installationSku");
const addonSkus = params.getAll("addonSku");
return {
accessMode,
installationSku: installationSku || null,
installationSku: installationSku ?? null,
addonSkus,
} as const;
}
export function useSimConfigureParams() {
const params = useSearchParams();
const simTypeParam = params.get("simType");
const simType = simTypeParam === "eSIM" || simTypeParam === "Physical SIM" ? simTypeParam : null;
const eid = params.get("eid");
const activationTypeParam = params.get("activationType");
const activationType =
activationTypeParam === "Immediate" || activationTypeParam === "Scheduled"
? activationTypeParam
: null;
const scheduledAt = params.get("scheduledAt") ?? params.get("scheduledDate");
const addonSkus = params.getAll("addonSku");
const isMnpParam = params.get("isMnp") ?? params.get("wantsMnp");
const isMnp = isMnpParam === "true";
// Optional detailed MNP fields if present
const mnp = {
reservationNumber:
params.get("mnpNumber") ??
params.get("reservationNumber") ??
params.get("mnp_reservationNumber") ??
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;
const simType = parseSimCardType(params.get("simType"));
const activationType = parseActivationType(params.get("activationType"));
const scheduledAt =
coalesce(params.get("scheduledAt"), params.get("scheduledDate")) ?? null;
const addonSkus = params.getAll("addonSku");
const isMnp =
coalesce(params.get("isMnp"), params.get("wantsMnp"))?.toLowerCase() === "true";
const eid = params.get("eid") ?? null;
const mnp: Partial<MnpData> = {
reservationNumber: coalesce(
params.get("mnpNumber"),
params.get("reservationNumber"),
params.get("mnp_reservationNumber")
),
expiryDate: coalesce(
params.get("mnpExpiry"),
params.get("expiryDate"),
params.get("mnp_expiryDate")
),
phoneNumber: coalesce(
params.get("mnpPhone"),
params.get("phoneNumber"),
params.get("mnp_phoneNumber")
),
mvnoAccountNumber: coalesce(
params.get("mvnoAccountNumber"),
params.get("mnp_mvnoAccountNumber")
),
portingLastName: coalesce(
params.get("portingLastName"),
params.get("mnp_portingLastName")
),
portingFirstName: coalesce(
params.get("portingFirstName"),
params.get("mnp_portingFirstName")
),
portingLastNameKatakana: coalesce(
params.get("portingLastNameKatakana"),
params.get("mnp_portingLastNameKatakana")
),
portingFirstNameKatakana: coalesce(
params.get("portingFirstNameKatakana"),
params.get("mnp_portingFirstNameKatakana")
),
portingGender: parsePortingGender(
coalesce(params.get("portingGender"), params.get("mnp_portingGender")) ?? null
),
portingDateOfBirth: coalesce(
params.get("portingDateOfBirth"),
params.get("mnp_portingDateOfBirth")
),
};
return {
simType,
eid: eid || null,
eid,
activationType,
scheduledAt: scheduledAt || null,
scheduledAt,
addonSkus,
isMnp,
mnp,

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState, useCallback } from "react";
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { useSimCatalog, useSimPlan, useSimConfigureParams } from ".";
import { useZodForm } from "@customer-portal/validation";
@ -59,6 +59,29 @@ export type UseSimConfigureResult = {
buildCheckoutSearchParams: () => URLSearchParams;
};
const parseSimCardTypeParam = (value: string | null): SimCardType | null => {
if (value === "eSIM" || value === "Physical SIM") {
return value;
}
return null;
};
const parseActivationTypeParam = (value: string | null): ActivationType | null => {
if (value === "Immediate" || value === "Scheduled") {
return value;
}
return null;
};
const parsePortingGenderParam = (
value: string | null
): MnpData["portingGender"] | undefined => {
if (value === "Male" || value === "Female" || value === "Corporate/Other") {
return value;
}
return undefined;
};
export function useSimConfigure(planId?: string): UseSimConfigureResult {
const searchParams = useSearchParams();
const { data: simData, isLoading: simLoading } = useSimCatalog();
@ -96,6 +119,22 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
initialValues,
});
const defaultMnpData: MnpData = useMemo(
() => ({
reservationNumber: "",
expiryDate: "",
phoneNumber: "",
mvnoAccountNumber: "",
portingLastName: "",
portingFirstName: "",
portingLastNameKatakana: "",
portingFirstNameKatakana: "",
portingGender: "",
portingDateOfBirth: "",
}),
[]
);
// Convenience setters that update the Zod form
const setSimType = useCallback((value: SimCardType) => setValue("simType", value), [setValue]);
const setEid = useCallback((value: string) => setValue("eid", value), [setValue]);
@ -114,90 +153,176 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const setWantsMnp = useCallback((value: boolean) => setValue("wantsMnp", value), [setValue]);
const setMnpData = useCallback((value: MnpData) => setValue("mnpData", value), [setValue]);
const searchParamsString = useMemo(() => searchParams.toString(), [searchParams]);
const parsedSearchParams = useMemo(
() => new URLSearchParams(searchParamsString),
[searchParamsString]
);
const resolvedParams = useMemo(() => {
const initialSimType =
configureParams.simType ??
parseSimCardTypeParam(parsedSearchParams.get("simType")) ??
"eSIM";
const initialActivationType =
configureParams.activationType ??
parseActivationTypeParam(parsedSearchParams.get("activationType")) ??
"Immediate";
const addonSkuSet = new Set<string>();
configureParams.addonSkus.forEach(sku => addonSkuSet.add(sku));
const addonQuery = parsedSearchParams.get("addons");
if (addonQuery) {
addonQuery
.split(",")
.map(sku => sku.trim())
.filter(Boolean)
.forEach(sku => addonSkuSet.add(sku));
}
const scheduledAtRaw =
configureParams.scheduledAt ??
parsedSearchParams.get("scheduledAt") ??
parsedSearchParams.get("scheduledDate") ??
"";
const scheduledActivationDate = scheduledAtRaw
? scheduledAtRaw.includes("-")
? scheduledAtRaw
: `${scheduledAtRaw.slice(0, 4)}-${scheduledAtRaw.slice(4, 6)}-${scheduledAtRaw.slice(6, 8)}`
: "";
const wantsMnp =
configureParams.isMnp ||
parsedSearchParams.get("isMnp") === "true" ||
parsedSearchParams.get("wantsMnp") === "true";
const resolveField = (value?: string | null) => value ?? "";
const paramFallback = (primary?: string | null, secondary?: string | null) =>
resolveField(primary ?? secondary ?? undefined);
const mnp = configureParams.mnp;
const resolvedMnpData: MnpData = wantsMnp
? {
reservationNumber: paramFallback(mnp.reservationNumber, parsedSearchParams.get("mnpNumber")),
expiryDate: paramFallback(mnp.expiryDate, parsedSearchParams.get("mnpExpiry")),
phoneNumber: paramFallback(mnp.phoneNumber, parsedSearchParams.get("mnpPhone")),
mvnoAccountNumber: paramFallback(
mnp.mvnoAccountNumber,
parsedSearchParams.get("mvnoAccountNumber")
),
portingLastName: paramFallback(mnp.portingLastName, parsedSearchParams.get("portingLastName")),
portingFirstName: paramFallback(
mnp.portingFirstName,
parsedSearchParams.get("portingFirstName")
),
portingLastNameKatakana: paramFallback(
mnp.portingLastNameKatakana,
parsedSearchParams.get("portingLastNameKatakana")
),
portingFirstNameKatakana: paramFallback(
mnp.portingFirstNameKatakana,
parsedSearchParams.get("portingFirstNameKatakana")
),
portingGender:
mnp.portingGender ??
parsePortingGenderParam(parsedSearchParams.get("portingGender")) ??
parsePortingGenderParam(parsedSearchParams.get("mnp_portingGender")) ??
"",
portingDateOfBirth: paramFallback(
mnp.portingDateOfBirth,
parsedSearchParams.get("portingDateOfBirth")
),
}
: {
reservationNumber: "",
expiryDate: "",
phoneNumber: "",
mvnoAccountNumber: "",
portingLastName: "",
portingFirstName: "",
portingLastNameKatakana: "",
portingFirstNameKatakana: "",
portingGender: "",
portingDateOfBirth: "",
};
return {
simType: initialSimType,
eid: configureParams.eid ?? parsedSearchParams.get("eid") ?? "",
selectedAddons: Array.from(addonSkuSet),
activationType: initialActivationType,
scheduledActivationDate,
wantsMnp,
mnpData: resolvedMnpData,
};
}, [configureParams, parsedSearchParams]);
const appliedParamsSignatureRef = useRef<string | null>(null);
// Initialize from URL params
useEffect(() => {
let mounted = true;
const initializeFromParams = () => {
if (simLoading || !simData) return;
if (simLoading || !simData) return;
if (mounted) {
// Set initial values from URL params or defaults
const initialSimType =
(configureParams.simType as SimCardType | null) ??
(searchParams.get("simType") as SimCardType) ??
"eSIM";
const initialActivationType =
(configureParams.activationType as ActivationType | null) ??
(searchParams.get("activationType") as ActivationType) ??
"Immediate";
const signature = JSON.stringify(resolvedParams);
if (appliedParamsSignatureRef.current === signature) {
return;
}
setSimType(initialSimType);
setEid(configureParams.eid ?? searchParams.get("eid") ?? "");
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);
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: "",
});
}
}
const arrayEquals = (a: string[], b: string[]) => {
if (a.length !== b.length) return false;
const setA = new Set(a);
return b.every(item => setA.has(item));
};
initializeFromParams();
return () => {
mounted = false;
const mnpEquals = (a: MnpData | undefined, b: MnpData) => {
const left = a ?? defaultMnpData;
return (
left.reservationNumber === b.reservationNumber &&
left.expiryDate === b.expiryDate &&
left.phoneNumber === b.phoneNumber &&
left.mvnoAccountNumber === b.mvnoAccountNumber &&
left.portingLastName === b.portingLastName &&
left.portingFirstName === b.portingFirstName &&
left.portingLastNameKatakana === b.portingLastNameKatakana &&
left.portingFirstNameKatakana === b.portingFirstNameKatakana &&
left.portingGender === b.portingGender &&
left.portingDateOfBirth === b.portingDateOfBirth
);
};
if (values.simType !== resolvedParams.simType) {
setSimType(resolvedParams.simType);
}
if (values.eid !== resolvedParams.eid) {
setEid(resolvedParams.eid);
}
if (!arrayEquals(values.selectedAddons, resolvedParams.selectedAddons)) {
setSelectedAddons(resolvedParams.selectedAddons);
}
if (values.activationType !== resolvedParams.activationType) {
setActivationType(resolvedParams.activationType);
}
if (values.scheduledActivationDate !== resolvedParams.scheduledActivationDate) {
setScheduledActivationDate(resolvedParams.scheduledActivationDate);
}
if (values.wantsMnp !== resolvedParams.wantsMnp) {
setWantsMnp(resolvedParams.wantsMnp);
}
if (!mnpEquals(values.mnpData, resolvedParams.mnpData)) {
setMnpData(resolvedParams.mnpData);
}
appliedParamsSignatureRef.current = signature;
}, [
simLoading,
simData,
searchParams,
configureParams,
resolvedParams,
setSimType,
setEid,
setSelectedAddons,
@ -205,6 +330,14 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
setScheduledActivationDate,
setWantsMnp,
setMnpData,
values.simType,
values.eid,
values.selectedAddons,
values.activationType,
values.scheduledActivationDate,
values.wantsMnp,
values.mnpData,
defaultMnpData,
]);
// Step transition handler (memoized)
@ -363,18 +496,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
setScheduledActivationDate,
wantsMnp: values.wantsMnp,
setWantsMnp,
mnpData: values.mnpData || {
reservationNumber: "",
expiryDate: "",
phoneNumber: "",
mvnoAccountNumber: "",
portingLastName: "",
portingFirstName: "",
portingLastNameKatakana: "",
portingFirstNameKatakana: "",
portingGender: "" as const,
portingDateOfBirth: "",
},
mnpData: values.mnpData || defaultMnpData,
setMnpData,
// Step orchestration

View File

@ -4,6 +4,7 @@ import {
type ApiErrorResponse,
type ApiSuccessResponse,
} from "@customer-portal/domain/common";
import type { ZodTypeAny, infer as ZodInfer } from "zod";
/**
* API Response Helper Types and Functions
@ -86,7 +87,12 @@ export function parseDomainError(payload: unknown): ApiErrorResponse | null {
return parsed.success ? parsed.data : null;
}
export function buildSuccessResponse<T>(data: T, schema: (value: T) => T): ApiSuccessResponse<T> {
return apiSuccessResponseSchema({ parse: () => schema(data) } as any).parse({ success: true, data });
export function buildSuccessResponse<T extends ZodTypeAny>(
data: ZodInfer<T>,
schema: T
): ApiSuccessResponse<ZodInfer<T>> {
return apiSuccessResponseSchema(schema).parse({
success: true,
data,
});
}

View File

@ -30,7 +30,7 @@ export function useCurrency() {
}
};
loadCurrency();
void loadCurrency();
}, []);
return {