Refactor BFF and portal components to enhance type consistency and error handling. Update type definitions across Freebit, WHMCS, and invoice management services, improving maintainability and clarity. Streamline service methods and import paths, while cleaning up unused code to ensure better organization throughout the project.

This commit is contained in:
barsa 2025-09-25 18:59:07 +09:00
parent b6d0aa1eb0
commit 29366d6ae6
39 changed files with 265 additions and 168 deletions

View File

@ -92,6 +92,7 @@ export interface FreebitTopUpRequest {
quota: number; // KB units (e.g., 102400 for 100MB)
quotaCode?: string; // Campaign code
expire?: string; // YYYYMMDD format
runTime?: string; // Scheduled execution time (YYYYMMDDHHmm)
}
export interface FreebitTopUpResponse {
@ -235,7 +236,13 @@ export interface FreebitCancelAccountResponse {
export interface FreebitEsimReissueRequest {
authKey: string;
account: string;
requestDatas: Array<{
kind: "MVNO";
account: string;
newEid?: string;
oldEid?: string;
planCode?: string;
}>;
}
export interface FreebitEsimReissueResponse {

View File

@ -144,7 +144,7 @@ export class FreebitOperationsService {
): Promise<void> {
try {
const quotaKb = Math.round(quotaMb * 1024);
const request: Omit<FreebitTopUpRequest, "authKey"> = {
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
account,
quota: quotaKb,
quotaCode: options.campaignCode,
@ -153,9 +153,7 @@ export class FreebitOperationsService {
const scheduled = !!options.scheduledAt;
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
if (scheduled) {
(request as any).runTime = options.scheduledAt;
}
const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest;
await this.client.makeAuthenticatedRequest<FreebitTopUpResponse, typeof request>(
endpoint,
@ -348,7 +346,7 @@ export class FreebitOperationsService {
try {
const request: Omit<FreebitEsimReissueRequest, "authKey"> = {
requestDatas: [{ kind: "MVNO", account }],
} as any;
};
await this.client.makeAuthenticatedRequest<FreebitEsimReissueResponse, typeof request>(
"/mvno/reissueEsim/",

View File

@ -1,12 +1,7 @@
import { Injectable } from "@nestjs/common";
import { FreebitOperationsService } from "./freebit-operations.service";
import { FreebitMapperService } from "./freebit-mapper.service";
import type {
SimDetails,
SimUsage,
SimTopUpHistory,
FreebitEsimAddAccountRequest,
} from "../interfaces/freebit.types";
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types";
@Injectable()
export class FreebitOrchestratorService {

View File

@ -15,12 +15,13 @@ import type {
SalesforcePubSubError,
SalesforcePubSubSubscription,
SalesforcePubSubCallbackType,
SalesforcePubSubUnknownData,
} from "../types/pubsub-events.types";
type SubscribeCallback = (
subscription: SalesforcePubSubSubscription,
callbackType: SalesforcePubSubCallbackType,
data: SalesforcePubSubEvent | SalesforcePubSubError | unknown
data: SalesforcePubSubEvent | SalesforcePubSubError | SalesforcePubSubUnknownData
) => void | Promise<void>;
interface PubSubClient {

View File

@ -33,8 +33,10 @@ export interface SalesforcePubSubError {
export type SalesforcePubSubCallbackType = "data" | "event" | "grpcstatus" | "end" | "error";
export type SalesforcePubSubUnknownData = Record<string, unknown> | null | undefined;
export interface SalesforcePubSubCallback {
subscription: SalesforcePubSubSubscription;
callbackType: SalesforcePubSubCallbackType;
data: SalesforcePubSubEvent | SalesforcePubSubError | unknown;
data: SalesforcePubSubEvent | SalesforcePubSubError | SalesforcePubSubUnknownData;
}

View File

@ -6,7 +6,6 @@ import { WhmcsHttpClientService } from "./whmcs-http-client.service";
import { WhmcsErrorHandlerService } from "./whmcs-error-handler.service";
import { WhmcsApiMethodsService } from "./whmcs-api-methods.service";
import type {
WhmcsApiResponse,
WhmcsErrorResponse,
WhmcsAddClientParams,
WhmcsValidateLoginParams,

View File

@ -44,7 +44,7 @@ export class WhmcsErrorHandlerService {
/**
* Handle general request errors (network, timeout, etc.)
*/
handleRequestError(error: unknown, action: string, params: Record<string, unknown>): never {
handleRequestError(error: unknown, action: string, _params: Record<string, unknown>): never {
const message = getErrorMessage(error);
if (this.isTimeoutError(error)) {

View File

@ -189,14 +189,53 @@ export class WhmcsHttpClientService {
// Add parameters
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
formData.append(key, String(value));
if (value === undefined || value === null) {
continue;
}
const serialized = this.serializeParamValue(value);
formData.append(key, serialized);
}
return formData.toString();
}
private serializeParamValue(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map(entry => this.serializeParamValue(entry)).join(",");
}
if (typeof value === "object" && value !== null) {
try {
return JSON.stringify(value);
} catch {
return Object.prototype.toString.call(value);
}
}
if (typeof value === "symbol") {
return value.description ? `Symbol(${value.description})` : "Symbol()";
}
if (typeof value === "function") {
return value.name ? `[Function ${value.name}]` : "[Function anonymous]";
}
return Object.prototype.toString.call(value);
}
/**
* Parse WHMCS API response
*/

View File

@ -4,7 +4,7 @@ import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Invoice, InvoiceList } from "@customer-portal/domain";
import {
invoiceListSchema,
invoiceSchema as invoiceEntitySchema,
invoiceSchema,
} from "@customer-portal/domain/validation/shared/entities";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service";
@ -15,9 +15,6 @@ import {
WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
WhmcsCreateInvoiceResponse,
WhmcsUpdateInvoiceResponse,
WhmcsCapturePaymentResponse,
} from "../types/whmcs-api.types";
export interface InvoiceFilters {
@ -88,7 +85,7 @@ export class WhmcsInvoiceService {
this.logger.log(
`Fetched ${result.invoices.length} invoices for client ${clientId}, page ${page}`
);
return result;
return result as InvoiceList;
} catch (error) {
this.logger.error(`Failed to fetch invoices for client ${clientId}`, {
error: getErrorMessage(error),
@ -117,7 +114,7 @@ export class WhmcsInvoiceService {
try {
// Get detailed invoice with items
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
const parseResult = invoiceEntitySchema.safeParse(detailedInvoice);
const parseResult = invoiceSchema.safeParse(detailedInvoice);
if (!parseResult.success) {
this.logger.error("Failed to parse detailed invoice", {
error: parseResult.error.issues,
@ -139,7 +136,7 @@ export class WhmcsInvoiceService {
);
const result: InvoiceList = {
invoices: invoicesWithItems,
invoices: invoicesWithItems as Invoice[],
pagination: invoiceList.pagination,
};
@ -184,7 +181,7 @@ export class WhmcsInvoiceService {
// Transform invoice
const invoice = this.invoiceTransformer.transformInvoice(response);
const parseResult = invoiceEntitySchema.safeParse(invoice);
const parseResult = invoiceSchema.safeParse(invoice);
if (!parseResult.success) {
throw new Error(`Invalid invoice data after transformation`);
}
@ -232,8 +229,8 @@ export class WhmcsInvoiceService {
for (const whmcsInvoice of response.invoices.invoice) {
try {
const transformed = this.invoiceTransformer.transformInvoice(whmcsInvoice);
const parsed = invoiceEntitySchema.parse(transformed);
invoices.push(parsed);
const parsed = invoiceSchema.parse(transformed);
invoices.push(parsed as Invoice);
} catch (error) {
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
error: getErrorMessage(error),

View File

@ -1,11 +1,7 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Invoice, InvoiceItem as BaseInvoiceItem } from "@customer-portal/domain";
import type {
WhmcsInvoice,
WhmcsInvoiceItems,
WhmcsCustomField,
} from "../../types/whmcs-api.types";
import type { WhmcsInvoice, WhmcsInvoiceItems } from "../../types/whmcs-api.types";
import { DataUtils } from "../utils/data-utils";
import { StatusNormalizer } from "../utils/status-normalizer";
import { TransformationValidator } from "../validators/transformation-validator";

View File

@ -131,7 +131,7 @@ export class SubscriptionTransformerService {
private normalizeFieldName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+(.)/g, (_, char) => char.toUpperCase())
.replace(/[^a-z0-9]+(.)/g, (_match: string, char: string) => char.toUpperCase())
.replace(/^[^a-z]+/, "");
}

View File

@ -30,7 +30,7 @@ export class WhmcsTransformerOrchestratorService {
/**
* Transform WHMCS invoice to our standard Invoice format
*/
async transformInvoice(whmcsInvoice: WhmcsInvoice): Promise<Invoice> {
transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice {
try {
return this.invoiceTransformer.transformInvoice(whmcsInvoice);
} catch (error) {
@ -60,7 +60,7 @@ export class WhmcsTransformerOrchestratorService {
/**
* Transform WHMCS product/service to our standard Subscription format
*/
async transformSubscription(whmcsProduct: WhmcsProduct): Promise<Subscription> {
transformSubscription(whmcsProduct: WhmcsProduct): Subscription {
try {
return this.subscriptionTransformer.transformSubscription(whmcsProduct);
} catch (error) {
@ -90,7 +90,7 @@ export class WhmcsTransformerOrchestratorService {
/**
* Transform WHMCS payment gateway to shared PaymentGateway interface
*/
async transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): Promise<PaymentGateway> {
transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): PaymentGateway {
try {
return this.paymentTransformer.transformPaymentGateway(whmcsGateway);
} catch (error) {
@ -120,7 +120,7 @@ export class WhmcsTransformerOrchestratorService {
/**
* Transform WHMCS payment method to shared PaymentMethod interface
*/
async transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): Promise<PaymentMethod> {
transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): PaymentMethod {
try {
return this.paymentTransformer.transformPaymentMethod(whmcsPayMethod);
} catch (error) {
@ -150,16 +150,16 @@ export class WhmcsTransformerOrchestratorService {
/**
* Transform multiple invoices in batch with error handling
*/
async transformInvoices(whmcsInvoices: WhmcsInvoice[]): Promise<{
transformInvoices(whmcsInvoices: WhmcsInvoice[]): {
successful: Invoice[];
failed: Array<{ invoice: WhmcsInvoice; error: string }>;
}> {
} {
const successful: Invoice[] = [];
const failed: Array<{ invoice: WhmcsInvoice; error: string }> = [];
for (const whmcsInvoice of whmcsInvoices) {
try {
const transformed = await this.transformInvoice(whmcsInvoice);
const transformed = this.transformInvoice(whmcsInvoice);
successful.push(transformed);
} catch (error) {
failed.push({
@ -181,16 +181,16 @@ export class WhmcsTransformerOrchestratorService {
/**
* Transform multiple subscriptions in batch with error handling
*/
async transformSubscriptions(whmcsProducts: WhmcsProduct[]): Promise<{
transformSubscriptions(whmcsProducts: WhmcsProduct[]): {
successful: Subscription[];
failed: Array<{ product: WhmcsProduct; error: string }>;
}> {
} {
const successful: Subscription[] = [];
const failed: Array<{ product: WhmcsProduct; error: string }> = [];
for (const whmcsProduct of whmcsProducts) {
try {
const transformed = await this.transformSubscription(whmcsProduct);
const transformed = this.transformSubscription(whmcsProduct);
successful.push(transformed);
} catch (error) {
failed.push({
@ -212,16 +212,16 @@ export class WhmcsTransformerOrchestratorService {
/**
* Transform multiple payment methods in batch with error handling
*/
async transformPaymentMethods(whmcsPayMethods: WhmcsPaymentMethod[]): Promise<{
transformPaymentMethods(whmcsPayMethods: WhmcsPaymentMethod[]): {
successful: PaymentMethod[];
failed: Array<{ payMethod: WhmcsPaymentMethod; error: string }>;
}> {
} {
const successful: PaymentMethod[] = [];
const failed: Array<{ payMethod: WhmcsPaymentMethod; error: string }> = [];
for (const whmcsPayMethod of whmcsPayMethods) {
try {
const transformed = await this.transformPaymentMethod(whmcsPayMethod);
const transformed = this.transformPaymentMethod(whmcsPayMethod);
successful.push(transformed);
} catch (error) {
failed.push({
@ -243,16 +243,16 @@ export class WhmcsTransformerOrchestratorService {
/**
* Transform multiple payment gateways in batch with error handling
*/
async transformPaymentGateways(whmcsGateways: WhmcsPaymentGateway[]): Promise<{
transformPaymentGateways(whmcsGateways: WhmcsPaymentGateway[]): {
successful: PaymentGateway[];
failed: Array<{ gateway: WhmcsPaymentGateway; error: string }>;
}> {
} {
const successful: PaymentGateway[] = [];
const failed: Array<{ gateway: WhmcsPaymentGateway; error: string }> = [];
for (const whmcsGateway of whmcsGateways) {
try {
const transformed = await this.transformPaymentGateway(whmcsGateway);
const transformed = this.transformPaymentGateway(whmcsGateway);
successful.push(transformed);
} catch (error) {
failed.push({

View File

@ -126,18 +126,59 @@ export class DataUtils {
if (!customFields) return undefined;
// Try exact match first
if (customFields[fieldName]) {
return String(customFields[fieldName]);
const directValue = DataUtils.toStringValue(customFields[fieldName]);
if (directValue !== undefined) {
return directValue;
}
// Try case-insensitive match
const lowerFieldName = fieldName.toLowerCase();
for (const [key, value] of Object.entries(customFields)) {
if (key.toLowerCase() === lowerFieldName) {
return String(value);
return DataUtils.toStringValue(value);
}
}
return undefined;
}
private static toStringValue(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map(entry => DataUtils.toStringValue(entry) ?? "").join(",");
}
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return Object.prototype.toString.call(value);
}
}
if (typeof value === "symbol") {
return value.description ? `Symbol(${value.description})` : "Symbol()";
}
if (typeof value === "function") {
return value.name ? `[Function ${value.name}]` : "[Function anonymous]";
}
return Object.prototype.toString.call(value);
}
}

View File

@ -1,3 +1,4 @@
import "tsconfig-paths/register";
import { Logger, type INestApplication } from "@nestjs/common";
import { bootstrap } from "./app/bootstrap";

View File

@ -173,10 +173,10 @@ export class InvoicesController {
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
@ApiOkResponse({ description: "List of related subscriptions" })
@ApiResponse({ status: 404, description: "Invoice not found" })
async getInvoiceSubscriptions(
getInvoiceSubscriptions(
@Request() req: AuthenticatedRequest,
@Param("id", ParseIntPipe) invoiceId: number
): Promise<Subscription[]> {
): Subscription[] {
if (invoiceId <= 0) {
throw new BadRequestException("Invoice ID must be a positive number");
}

View File

@ -1,13 +1,6 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import {
Invoice,
InvoiceList,
InvoiceSsoLink,
InvoicePaymentLink,
PaymentMethodList,
PaymentGatewayList,
} from "@customer-portal/domain";
import { Invoice, InvoiceList } from "@customer-portal/domain";
import { InvoiceRetrievalService } from "./invoice-retrieval.service";
import { InvoiceHealthService } from "./invoice-health.service";
import { InvoiceValidatorService } from "../validators/invoice-validator.service";

View File

@ -13,6 +13,7 @@ import type {
SimTopUpHistoryRequest,
SimFeaturesUpdateRequest,
} from "./sim-management/types/sim-requests.types";
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
@Injectable()
export class SimManagementService {
@ -25,9 +26,9 @@ export class SimManagementService {
private async notifySimAction(
action: string,
status: "SUCCESS" | "ERROR",
context: Record<string, unknown>
context: SimNotificationContext
): Promise<void> {
return this.simNotification.notifySimAction(action, status, context as any);
return this.simNotification.notifySimAction(action, status, context);
}
/**

View File

@ -6,7 +6,7 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { Logger } from "nestjs-pino";
import { z } from "zod";
import { subscriptionSchema } from "@customer-portal/domain/validation/shared/entities";
import { subscriptionSchema } from "@customer-portal/domain";
import type {
WhmcsProduct,
WhmcsProductsResponse,

View File

@ -13,7 +13,6 @@
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"],
"references": [
{ "path": "../../packages/domain" },
{ "path": "../../packages/validation" }
{ "path": "../../packages/domain" }
]
}

View File

@ -3,14 +3,14 @@ export type PostCall = [path: string, options?: unknown];
export const postCalls: PostCall[] = [];
export const apiClient = {
POST: async (path: string, options?: unknown) => {
POST: (path: string, options?: unknown) => {
postCalls.push([path, options]);
return { data: null } as const;
return Promise.resolve({ data: null } as const);
},
GET: async () => ({ data: null }) as const,
PUT: async () => ({ data: null }) as const,
PATCH: async () => ({ data: null }) as const,
DELETE: async () => ({ data: null }) as const,
GET: () => Promise.resolve({ data: null } as const),
PUT: () => Promise.resolve({ data: null } as const),
PATCH: () => Promise.resolve({ data: null } as const),
DELETE: () => Promise.resolve({ data: null } as const),
};
export const configureApiClientAuth = () => undefined;

View File

@ -1,4 +1,6 @@
#!/usr/bin/env node
/* eslint-env node */
/* global __dirname, console, process */
const fs = require("node:fs");
const path = require("node:path");
@ -94,7 +96,7 @@ const { useAuthStore } = require("../src/features/auth/services/auth.store.ts");
const [endpoint, options] = coreApiStub.postCalls[0];
if (endpoint !== "/auth/request-password-reset") {
throw new Error(
`Expected endpoint \"/auth/request-password-reset\" but received \"${endpoint}\"`
`Expected endpoint "/auth/request-password-reset" but received "${endpoint}"`
);
}
@ -109,7 +111,7 @@ const { useAuthStore } = require("../src/features/auth/services/auth.store.ts");
if (body.email !== payload.email) {
throw new Error(
`Expected request body email to be \"${payload.email}\" but received \"${body.email}\"`
`Expected request body email to be "${payload.email}" but received "${body.email}"`
);
}

View File

@ -140,10 +140,9 @@ const NavigationItem = memo(function NavigationItem({
href={child.href}
prefetch
onMouseEnter={() => {
try {
// Warm up code/data for faster clicks
if (child.href) router.prefetch(child.href);
} catch {}
if (child.href) {
void router.prefetch(child.href);
}
}}
className={`group flex items-center px-3 py-2 text-sm rounded-md transition-all duration-200 relative ${
isChildActive
@ -194,9 +193,9 @@ const NavigationItem = memo(function NavigationItem({
href={item.href || "#"}
prefetch
onMouseEnter={() => {
try {
if (item.href && item.href !== "#") router.prefetch(item.href);
} catch {}
if (item.href && item.href !== "#") {
void router.prefetch(item.href);
}
}}
className={`group w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-200 relative ${
isActive

View File

@ -11,12 +11,8 @@ import { useZodForm } from "@customer-portal/validation";
export function useAddressEdit(initial: AddressFormData) {
const handleSave = useCallback(async (formData: AddressFormData) => {
try {
const requestData = addressFormToRequest(formData);
await accountService.updateAddress(requestData);
} catch (error) {
throw error; // Let useZodForm handle the error state
}
const requestData = addressFormToRequest(formData);
await accountService.updateAddress(requestData);
}, []);
return useZodForm({

View File

@ -12,17 +12,13 @@ import { useZodForm } from "@customer-portal/validation";
export function useProfileEdit(initial: ProfileEditFormData) {
const handleSave = useCallback(async (formData: ProfileEditFormData) => {
try {
const requestData = profileFormToRequest(formData);
const updated = await accountService.updateProfile(requestData);
const requestData = profileFormToRequest(formData);
const updated = await accountService.updateProfile(requestData);
useAuthStore.setState(state => ({
...state,
user: state.user ? { ...state.user, ...updated } : state.user,
}));
} catch (error) {
throw error; // Let useZodForm handle the error state
}
useAuthStore.setState(state => ({
...state,
user: state.user ? { ...state.user, ...updated } : state.user,
}));
}, []);
return useZodForm({

View File

@ -22,17 +22,12 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
const handleLink = useCallback(
async (formData: LinkWhmcsFormData) => {
clearError();
try {
const payload: LinkWhmcsRequestInput = {
email: formData.email,
password: formData.password,
};
const result = await linkWhmcs(payload);
onTransferred?.({ ...result, email: formData.email });
} catch (err) {
// Error is handled by useZodForm
throw err;
}
const payload: LinkWhmcsRequestInput = {
email: formData.email,
password: formData.password,
};
const result = await linkWhmcs(payload);
onTransferred?.({ ...result, email: formData.email });
},
[linkWhmcs, onTransferred, clearError]
);
@ -56,7 +51,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={event => void handleSubmit(event)} className="space-y-4">
<FormField label="Email Address" error={errors.email} required>
<Input
type="email"

View File

@ -10,7 +10,7 @@ import Link from "next/link";
import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { useLogin } from "../../hooks/use-auth";
import { loginFormSchema, loginFormToRequest, type LoginFormData } from "@customer-portal/domain";
import { loginFormSchema, loginFormToRequest } from "@customer-portal/domain";
import { useZodForm } from "@customer-portal/validation";
import { z } from "zod";
@ -40,14 +40,14 @@ export function LoginForm({
const handleLogin = useCallback(
async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => {
clearError();
const requestData = loginFormToRequest(formData);
try {
const requestData = loginFormToRequest(formData);
await login(requestData);
onSuccess?.();
} catch (err) {
const message = err instanceof Error ? err.message : "Login failed";
onError?.(message);
throw err; // Re-throw to let useZodForm handle the error state
throw err;
}
},
[login, onSuccess, onError, clearError]
@ -61,7 +61,6 @@ export function LoginForm({
setValue,
setTouchedField,
handleSubmit,
validateField,
} = useZodForm<LoginFormValues>({
schema: loginSchema,
initialValues: {
@ -74,7 +73,7 @@ export function LoginForm({
return (
<div className={`w-full max-w-md mx-auto ${className}`}>
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={event => void handleSubmit(event)} className="space-y-6">
<FormField label="Email Address" error={touched.email ? errors.email : undefined} required>
<Input
type="email"
@ -143,7 +142,7 @@ export function LoginForm({
{showSignupLink && (
<div className="text-center">
<p className="text-sm text-gray-600">
Don't have an account?{" "}
Don&apos;t have an account?{" "}
<Link
href="/auth/signup"
className="font-medium text-blue-600 hover:text-blue-500 transition-colors"

View File

@ -49,6 +49,7 @@ export function PasswordResetForm({
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Request failed";
onError?.(errorMessage);
throw err;
}
},
});
@ -80,6 +81,7 @@ export function PasswordResetForm({
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Reset failed";
onError?.(errorMessage);
throw err;
}
},
});
@ -107,11 +109,11 @@ export function PasswordResetForm({
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900">Reset your password</h2>
<p className="mt-2 text-sm text-gray-600">
Enter your email address and we'll send you a link to reset your password.
Enter your email address and we&apos;ll send you a link to reset your password.
</p>
</div>
<form onSubmit={requestForm.handleSubmit} className="space-y-4">
<form onSubmit={event => void requestForm.handleSubmit(event)} className="space-y-4">
<FormField label="Email address" error={requestForm.errors.email} required>
<Input
type="email"
@ -155,7 +157,7 @@ export function PasswordResetForm({
<p className="mt-2 text-sm text-gray-600">Enter your new password below.</p>
</div>
<form onSubmit={resetForm.handleSubmit} className="space-y-4">
<form onSubmit={event => void resetForm.handleSubmit(event)} className="space-y-4">
<FormField label="New password" error={resetForm.errors.password} required>
<Input
type="password"

View File

@ -62,6 +62,7 @@ export function SetPasswordForm({
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to set password";
onError?.(errorMessage);
throw err;
}
},
});
@ -89,7 +90,7 @@ export function SetPasswordForm({
</p>
</div>
<form onSubmit={form.handleSubmit} className="space-y-4">
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
<FormField label="Email address" error={form.errors.email} required>
<Input
type="email"

View File

@ -10,11 +10,10 @@ import { FormField } from "@/components/molecules/FormField/FormField";
import type { UseZodFormReturn } from "@customer-portal/validation";
import type { SignupFormValues } from "./SignupForm";
interface PasswordStepProps
extends Pick<
UseZodFormReturn<SignupFormValues>,
"values" | "errors" | "touched" | "setValue" | "setTouchedField"
> {}
type PasswordStepProps = Pick<
UseZodFormReturn<SignupFormValues>,
"values" | "errors" | "touched" | "setValue" | "setTouchedField"
>;
export function PasswordStep({
values,

View File

@ -319,8 +319,10 @@ export const useAuthStore = create<AuthState>()(
set({ user: profile });
} catch (error) {
// Token might be expired, try to refresh
handleAuthError(error, get().logout);
await get().refreshTokens();
const shouldLogout = handleAuthError(error, get().logout);
if (!shouldLogout) {
await get().refreshTokens();
}
}
},
@ -328,7 +330,7 @@ export const useAuthStore = create<AuthState>()(
const { tokens } = get();
if (!tokens?.refreshToken) {
// No refresh token available, logout
get().logout();
await get().logout();
return;
}
@ -349,7 +351,10 @@ export const useAuthStore = create<AuthState>()(
set({ tokens: newTokens, isAuthenticated: true });
} catch (error) {
// Refresh failed, logout
handleAuthError(error, get().logout);
const shouldLogout = handleAuthError(error, get().logout);
if (!shouldLogout) {
await get().logout();
}
}
},

View File

@ -7,7 +7,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { useSession } from "@/features/auth/hooks";
import { useAuthStore } from "@/features/auth/services/auth.store";
import { apiClient, getDataOrThrow } from "@/lib/api";
import { apiClient, getDataOrThrow, isApiError } from "@/lib/api";
import { openSsoLink } from "@/features/billing/utils/sso";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import { PaymentMethodCard, usePaymentMethods } from "@/features/billing";
@ -62,9 +62,9 @@ export function PaymentMethodsContainer() {
});
const sso = getDataOrThrow<InvoiceSsoLink>(response, "Failed to open payment methods portal");
openSsoLink(sso.url, { newTab: true });
} catch (err) {
} catch (err: unknown) {
logger.error(err, "Failed to open payment methods");
if (err && typeof err === "object" && "status" in err && (err as any).status === 401) {
if (isApiError(err) && err.response.status === 401) {
setError("Authentication failed. Please log in again.");
} else {
setError("Unable to access payment methods. Please try again later.");

View File

@ -46,8 +46,8 @@ export function ServiceConfigurationStep({ plan, mode, setMode, isTransitioning,
from our tech team for details.
</p>
<p className="text-xs mt-2">
* Will appear on the invoice as "Platinum Base Plan". Device subscriptions will be added
later.
* Will appear on the invoice as &quot;Platinum Base Plan&quot;. Device subscriptions will be
added later.
</p>
</AlertBanner>
)}

View File

@ -93,8 +93,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const { values, errors, setValue, validate } = useZodForm<SimConfigureFormData>({
schema: simConfigureFormSchema,
initialValues,
onSubmit: async data => {
// This hook doesn't submit directly, just validates
onSubmit: data => {
simConfigureFormToRequest(data);
},
});
@ -120,7 +119,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
// Initialize from URL params
useEffect(() => {
let mounted = true;
async function initializeFromParams() {
const initializeFromParams = () => {
if (simLoading || !simData) return;
if (mounted) {
@ -136,9 +135,9 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
setScheduledActivationDate(searchParams.get("scheduledDate") || "");
setWantsMnp(searchParams.get("wantsMnp") === "true");
}
}
};
void initializeFromParams();
initializeFromParams();
return () => {
mounted = false;
};
@ -173,25 +172,37 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
// Add addon pricing
if (simData?.addons) {
values.selectedAddons.forEach((addonId: string) => {
values.selectedAddons.forEach(addonId => {
const addon = simData.addons.find(a => a.id === addonId);
if (addon) {
if ((addon as any).billingType === "monthly") {
monthly += (addon as any).price || 0;
} else {
oneTime += (addon as any).price || 0;
}
if (!addon) return;
const billingType =
("billingType" in addon && typeof (addon as { billingType?: string }).billingType === "string"
? (addon as { billingType?: string }).billingType
: addon.billingCycle) ?? "";
const normalizedBilling = billingType.toLowerCase();
const recurringValue = addon.monthlyPrice ?? addon.unitPrice ?? 0;
const oneTimeValue = addon.oneTimePrice ?? addon.unitPrice ?? addon.monthlyPrice ?? 0;
if (normalizedBilling === "monthly") {
monthly += recurringValue;
} else {
oneTime += oneTimeValue;
}
});
}
// Add activation fees
if (simData?.activationFees) {
const activationFee = simData.activationFees.find(
fee => (fee as any).simType === values.simType
);
const activationFee = simData.activationFees.find(fee => {
const rawSimType =
"simType" in fee && typeof (fee as { simType?: string }).simType === "string"
? (fee as { simType?: string }).simType
: undefined;
return (rawSimType ?? fee.simPlanType) === values.simType;
});
if (activationFee) {
oneTime += (activationFee as any).amount || 0;
oneTime += activationFee.oneTimePrice ?? activationFee.unitPrice ?? activationFee.monthlyPrice ?? 0;
}
}

View File

@ -3,6 +3,14 @@ import type { CreateOrderRequest } from "@customer-portal/domain";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:4000";
interface AuthStoreSnapshot {
state?: {
tokens?: {
accessToken?: unknown;
};
};
}
const getAuthHeader = (): string | undefined => {
if (typeof window === "undefined") return undefined;
@ -10,9 +18,9 @@ const getAuthHeader = (): string | undefined => {
if (!authStore) return undefined;
try {
const parsed = JSON.parse(authStore);
const parsed = JSON.parse(authStore) as AuthStoreSnapshot;
const token = parsed?.state?.tokens?.accessToken;
return token ? `Bearer ${token}` : undefined;
return typeof token === "string" && token ? `Bearer ${token}` : undefined;
} catch {
return undefined;
}

View File

@ -19,17 +19,17 @@ export const queryKeys = {
session: () => ["auth", "session"] as const,
},
billing: {
invoices: (params?: Record<string, any>) => ["billing", "invoices", params] as const,
invoices: (params?: Record<string, unknown>) => ["billing", "invoices", params] as const,
invoice: (id: string) => ["billing", "invoice", id] as const,
paymentMethods: () => ["billing", "payment-methods"] as const,
},
subscriptions: {
all: () => ["subscriptions"] as const,
list: (params?: Record<string, any>) => ["subscriptions", "list", params] as const,
list: (params?: Record<string, unknown>) => ["subscriptions", "list", params] as const,
active: () => ["subscriptions", "active"] as const,
stats: () => ["subscriptions", "stats"] as const,
detail: (id: string) => ["subscriptions", "detail", id] as const,
invoices: (id: number, params?: Record<string, any>) =>
invoices: (id: number, params?: Record<string, unknown>) =>
["subscriptions", "invoices", id, params] as const,
},
dashboard: {

View File

@ -85,6 +85,21 @@ export interface CreateClientOptions {
handleError?: (response: Response) => void | Promise<void>;
}
const getBodyMessage = (body: unknown): string | null => {
if (typeof body === "string") {
return body;
}
if (typeof body === "object" && body !== null && "message" in body) {
const maybeMessage = (body as { message?: unknown }).message;
if (typeof maybeMessage === "string") {
return maybeMessage;
}
}
return null;
};
async function defaultHandleError(response: Response) {
if (response.ok) return;
@ -96,13 +111,9 @@ async function defaultHandleError(response: Response) {
const contentType = cloned.headers.get("content-type");
if (contentType?.includes("application/json")) {
body = await cloned.json();
if (
body &&
typeof body === "object" &&
"message" in body &&
typeof (body as any).message === "string"
) {
message = (body as any).message;
const jsonMessage = getBodyMessage(body);
if (jsonMessage) {
message = jsonMessage;
}
} else {
const text = await cloned.text();

View File

@ -26,7 +26,8 @@ export function useLocalStorage<T>(
try {
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
const parsed: unknown = JSON.parse(item);
setStoredValue(parsed as T);
}
} catch (error) {
logger.warn(

View File

@ -116,12 +116,15 @@ export function getUserFriendlyMessage(error: unknown): string {
/**
* Handle authentication errors consistently
*/
export function handleAuthError(error: unknown, logout: () => void): void {
export function handleAuthError(error: unknown, logout: () => void | Promise<void>): boolean {
const errorInfo = getErrorInfo(error);
if (errorInfo.shouldLogout) {
logout();
void logout();
return true;
}
return false;
}
/**

View File

@ -1,8 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"module": "NodeNext",
"moduleResolution": "nodenext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",