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:
parent
e42d474048
commit
6567bc5907
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ export function useCurrency() {
|
||||
}
|
||||
};
|
||||
|
||||
loadCurrency();
|
||||
void loadCurrency();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user