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"); 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, oemId: this.config.oemId,
oemKey: this.config.oemKey, oemKey: this.config.oemKey,
}); });
@ -84,8 +84,9 @@ export class FreebitAuthService {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} }
const json = (await response.json()) as unknown; const json: unknown = await response.json();
const data = FreebitProvider.mapper.transformFreebitAuthResponse(json); const data: FreebitAuthResponse =
FreebitProvider.mapper.transformFreebitAuthResponse(json);
if (data.resultCode !== "100" || !data.authKey) { if (data.resultCode !== "100" || !data.authKey) {
throw new FreebitError( throw new FreebitError(

View File

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

View File

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

View File

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

View File

@ -47,8 +47,9 @@ import {
type AuthTokens, type AuthTokens,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
type RequestWithCookies = Request & { type CookieValue = string | undefined;
cookies: Record<string, any>; type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, CookieValue>;
}; };
const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => { 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 { IS_PUBLIC_KEY } from "../../../decorators/public.decorator";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
type RequestWithCookies = Request & { type CookieValue = string | undefined;
cookies: Record<string, any>; type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, CookieValue>;
}; };
type RequestWithRoute = RequestWithCookies & { type RequestWithRoute = RequestWithCookies & {
method: string; method: string;

View File

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

View File

@ -60,9 +60,13 @@ export class SimPlanService {
scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default", 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, { const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
assignGlobalIp, assignGlobalIp,
scheduledAt: scheduledAt!, scheduledAt,
}); });
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { 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); const profile = await this.getProfile(userId);
currency = profile.currency_code || currency; currency = profile.currency_code || currency;
} catch (error) { } 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 = { const summary: DashboardSummary = {
@ -529,14 +532,15 @@ export class UsersService {
let currency = "JPY"; // Default let currency = "JPY"; // Default
try { try {
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
if (client && typeof client === 'object' && 'currency_code' in client) { const currencyCode = client.currency_code ?? client.raw.currency_code ?? null;
const currency_code = (client as any).currency_code; if (currencyCode) {
if (currency_code) { currency = currencyCode;
currency = currency_code;
}
} }
} catch (error) { } 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 = { const summary: DashboardSummary = {

View File

@ -1,10 +1,23 @@
/// <reference types="jest" /> /// <reference types="jest" />
import request from "supertest"; import request from "supertest";
import { INestApplication } from "@nestjs/common"; import type { INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing"; import { Test } from "@nestjs/testing";
import type { Server } from "node:http";
import { AppModule } from "../src/app.module"; 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", () => { describe("Catalog contract", () => {
let app: INestApplication; let app: INestApplication;
@ -23,11 +36,15 @@ describe("Catalog contract", () => {
}); });
it("should return internet catalog matching domain schema", async () => { 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(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" "module": "commonjs"
} }
}, },
"include": ["src/**/*", "scripts/**/*"], "include": ["src/**/*", "scripts/**/*", "test/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] "exclude": ["node_modules", "dist"]
} }

View File

@ -229,7 +229,9 @@ export function InvoiceTable({
<Button <Button
size="sm" size="sm"
variant="default" variant="default"
onClick={(e) => handlePayment(invoice, e)} onClick={event => {
void handlePayment(invoice, event);
}}
loading={isPaymentLoading} loading={isPaymentLoading}
className="text-xs font-medium shadow-sm" className="text-xs font-medium shadow-sm"
> >
@ -241,7 +243,9 @@ export function InvoiceTable({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={(e) => handleDownload(invoice, e)} onClick={event => {
void handleDownload(invoice, event);
}}
loading={isDownloadLoading} loading={isDownloadLoading}
leftIcon={!isDownloadLoading ? <ArrowDownTrayIcon className="h-4 w-4" /> : undefined} 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" 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 // When true, attaches focus/visibility listeners to refresh automatically
attachFocusListeners?: boolean; attachFocusListeners?: boolean;
// Optional custom detector for whether payment methods exist // Optional custom detector for whether payment methods exist
hasMethods?: (data?: PaymentMethodList | undefined) => boolean; hasMethods?: (data?: PaymentMethodList) => boolean;
} }
export function usePaymentRefresh({ export function usePaymentRefresh({

View File

@ -1,85 +1,121 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import type { SimCardType, ActivationType, MnpData } from "@customer-portal/domain/sim";
export type AccessMode = "IPoE-BYOR" | "PPPoE"; 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() { export function useInternetConfigureParams() {
const params = useSearchParams(); const params = useSearchParams();
const accessModeParam = params.get("accessMode"); const accessModeParam = params.get("accessMode");
const accessMode = const accessMode: AccessMode | null =
accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE" accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE"
? (accessModeParam as AccessMode) ? accessModeParam
: null; : null;
const installationSku = params.get("installationSku"); const installationSku = params.get("installationSku");
const addonSkus = params.getAll("addonSku"); const addonSkus = params.getAll("addonSku");
return { return {
accessMode, accessMode,
installationSku: installationSku || null, installationSku: installationSku ?? null,
addonSkus, addonSkus,
} as const; } as const;
} }
export function useSimConfigureParams() { export function useSimConfigureParams() {
const params = useSearchParams(); 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 simType = parseSimCardType(params.get("simType"));
const mnp = { const activationType = parseActivationType(params.get("activationType"));
reservationNumber: const scheduledAt =
params.get("mnpNumber") ?? coalesce(params.get("scheduledAt"), params.get("scheduledDate")) ?? null;
params.get("reservationNumber") ?? const addonSkus = params.getAll("addonSku");
params.get("mnp_reservationNumber") ?? const isMnp =
undefined, coalesce(params.get("isMnp"), params.get("wantsMnp"))?.toLowerCase() === "true";
expiryDate: const eid = params.get("eid") ?? null;
params.get("mnpExpiry") ??
params.get("expiryDate") ?? const mnp: Partial<MnpData> = {
params.get("mnp_expiryDate") ?? reservationNumber: coalesce(
undefined, params.get("mnpNumber"),
phoneNumber: params.get("reservationNumber"),
params.get("mnpPhone") ?? params.get("mnp_reservationNumber")
params.get("phoneNumber") ?? ),
params.get("mnp_phoneNumber") ?? expiryDate: coalesce(
undefined, params.get("mnpExpiry"),
mvnoAccountNumber: params.get("expiryDate"),
params.get("mvnoAccountNumber") ?? params.get("mnp_mvnoAccountNumber") ?? undefined, params.get("mnp_expiryDate")
portingLastName: ),
params.get("portingLastName") ?? params.get("mnp_portingLastName") ?? undefined, phoneNumber: coalesce(
portingFirstName: params.get("mnpPhone"),
params.get("portingFirstName") ?? params.get("mnp_portingFirstName") ?? undefined, params.get("phoneNumber"),
portingLastNameKatakana: params.get("mnp_phoneNumber")
params.get("portingLastNameKatakana") ?? ),
params.get("mnp_portingLastNameKatakana") ?? mvnoAccountNumber: coalesce(
undefined, params.get("mvnoAccountNumber"),
portingFirstNameKatakana: params.get("mnp_mvnoAccountNumber")
params.get("portingFirstNameKatakana") ?? ),
params.get("mnp_portingFirstNameKatakana") ?? portingLastName: coalesce(
undefined, params.get("portingLastName"),
portingGender: params.get("mnp_portingLastName")
(params.get("portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ?? ),
(params.get("mnp_portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ?? portingFirstName: coalesce(
undefined, params.get("portingFirstName"),
portingDateOfBirth: params.get("mnp_portingFirstName")
params.get("portingDateOfBirth") ?? params.get("mnp_portingDateOfBirth") ?? undefined, ),
} as const; 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 { return {
simType, simType,
eid: eid || null, eid,
activationType, activationType,
scheduledAt: scheduledAt || null, scheduledAt,
addonSkus, addonSkus,
isMnp, isMnp,
mnp, mnp,

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState, useCallback } from "react"; import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useSimCatalog, useSimPlan, useSimConfigureParams } from "."; import { useSimCatalog, useSimPlan, useSimConfigureParams } from ".";
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
@ -59,6 +59,29 @@ export type UseSimConfigureResult = {
buildCheckoutSearchParams: () => URLSearchParams; 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 { export function useSimConfigure(planId?: string): UseSimConfigureResult {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { data: simData, isLoading: simLoading } = useSimCatalog(); const { data: simData, isLoading: simLoading } = useSimCatalog();
@ -96,6 +119,22 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
initialValues, initialValues,
}); });
const defaultMnpData: MnpData = useMemo(
() => ({
reservationNumber: "",
expiryDate: "",
phoneNumber: "",
mvnoAccountNumber: "",
portingLastName: "",
portingFirstName: "",
portingLastNameKatakana: "",
portingFirstNameKatakana: "",
portingGender: "",
portingDateOfBirth: "",
}),
[]
);
// Convenience setters that update the Zod form // Convenience setters that update the Zod form
const setSimType = useCallback((value: SimCardType) => 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]);
@ -114,90 +153,176 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const setWantsMnp = useCallback((value: boolean) => setValue("wantsMnp", value), [setValue]); const setWantsMnp = useCallback((value: boolean) => setValue("wantsMnp", value), [setValue]);
const setMnpData = useCallback((value: MnpData) => setValue("mnpData", 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 // Initialize from URL params
useEffect(() => { useEffect(() => {
let mounted = true; if (simLoading || !simData) return;
const initializeFromParams = () => {
if (simLoading || !simData) return;
if (mounted) { const signature = JSON.stringify(resolvedParams);
// Set initial values from URL params or defaults if (appliedParamsSignatureRef.current === signature) {
const initialSimType = return;
(configureParams.simType as SimCardType | null) ?? }
(searchParams.get("simType") as SimCardType) ??
"eSIM";
const initialActivationType =
(configureParams.activationType as ActivationType | null) ??
(searchParams.get("activationType") as ActivationType) ??
"Immediate";
setSimType(initialSimType); const arrayEquals = (a: string[], b: string[]) => {
setEid(configureParams.eid ?? searchParams.get("eid") ?? ""); if (a.length !== b.length) return false;
const addonSkuSet = new Set<string>(); const setA = new Set(a);
configureParams.addonSkus.forEach(sku => addonSkuSet.add(sku)); return b.every(item => setA.has(item));
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: "",
});
}
}
}; };
initializeFromParams(); const mnpEquals = (a: MnpData | undefined, b: MnpData) => {
return () => { const left = a ?? defaultMnpData;
mounted = false; 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, simLoading,
simData, simData,
searchParams, resolvedParams,
configureParams,
setSimType, setSimType,
setEid, setEid,
setSelectedAddons, setSelectedAddons,
@ -205,6 +330,14 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
setScheduledActivationDate, setScheduledActivationDate,
setWantsMnp, setWantsMnp,
setMnpData, setMnpData,
values.simType,
values.eid,
values.selectedAddons,
values.activationType,
values.scheduledActivationDate,
values.wantsMnp,
values.mnpData,
defaultMnpData,
]); ]);
// Step transition handler (memoized) // Step transition handler (memoized)
@ -363,18 +496,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
setScheduledActivationDate, setScheduledActivationDate,
wantsMnp: values.wantsMnp, wantsMnp: values.wantsMnp,
setWantsMnp, setWantsMnp,
mnpData: values.mnpData || { mnpData: values.mnpData || defaultMnpData,
reservationNumber: "",
expiryDate: "",
phoneNumber: "",
mvnoAccountNumber: "",
portingLastName: "",
portingFirstName: "",
portingLastNameKatakana: "",
portingFirstNameKatakana: "",
portingGender: "" as const,
portingDateOfBirth: "",
},
setMnpData, setMnpData,
// Step orchestration // Step orchestration

View File

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