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