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:
parent
b6d0aa1eb0
commit
29366d6ae6
@ -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 {
|
||||
|
||||
@ -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/",
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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]+/, "");
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import "tsconfig-paths/register";
|
||||
import { Logger, type INestApplication } from "@nestjs/common";
|
||||
|
||||
import { bootstrap } from "./app/bootstrap";
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"],
|
||||
"references": [
|
||||
{ "path": "../../packages/domain" },
|
||||
{ "path": "../../packages/validation" }
|
||||
{ "path": "../../packages/domain" }
|
||||
]
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}"`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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't have an account?{" "}
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 transition-colors"
|
||||
|
||||
@ -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'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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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 "Platinum Base Plan". Device subscriptions will be
|
||||
added later.
|
||||
</p>
|
||||
</AlertBanner>
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user