Refactor order and subscription services for improved type safety and error handling
- Updated billing cycle assignment in OrderOrchestrator to ensure proper type handling. - Enhanced error handling in SimManagementService and related components to use more specific types for exceptions. - Standardized error handling across various components to improve consistency and clarity. - Adjusted function signatures in multiple services and controllers to return more precise types, enhancing type safety.
This commit is contained in:
parent
05817e8c67
commit
de35397cf9
@ -261,7 +261,10 @@ export class OrderOrchestrator {
|
|||||||
quantity: item.Quantity,
|
quantity: item.Quantity,
|
||||||
unitPrice: item.UnitPrice,
|
unitPrice: item.UnitPrice,
|
||||||
totalPrice: item.TotalPrice,
|
totalPrice: item.TotalPrice,
|
||||||
billingCycle: String(item.PricebookEntry?.Product2?.Billing_Cycle__c || ""),
|
billingCycle: ((): string | undefined => {
|
||||||
|
const v = item.PricebookEntry?.Product2?.Billing_Cycle__c;
|
||||||
|
return typeof v === "string" ? v : undefined;
|
||||||
|
})(),
|
||||||
});
|
});
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebititService } from "../vendors/freebit/freebit.service";
|
import { FreebititService } from "../vendors/freebit/freebit.service";
|
||||||
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
||||||
@ -46,7 +46,10 @@ export class SimManagementService {
|
|||||||
/**
|
/**
|
||||||
* Debug method to check subscription data for SIM services
|
* Debug method to check subscription data for SIM services
|
||||||
*/
|
*/
|
||||||
async debugSimSubscription(userId: string, subscriptionId: number): Promise<any> {
|
async debugSimSubscription(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const subscription = await this.subscriptionsService.getSubscriptionById(
|
const subscription = await this.subscriptionsService.getSubscriptionById(
|
||||||
userId,
|
userId,
|
||||||
@ -58,11 +61,11 @@ export class SimManagementService {
|
|||||||
const expectedEid = "89049032000001000000043598005455";
|
const expectedEid = "89049032000001000000043598005455";
|
||||||
|
|
||||||
const simNumberField = Object.entries(subscription.customFields || {}).find(
|
const simNumberField = Object.entries(subscription.customFields || {}).find(
|
||||||
([key, value]) => value && value.toString().includes(expectedSimNumber)
|
([_key, value]) => value && value.toString().includes(expectedSimNumber)
|
||||||
);
|
);
|
||||||
|
|
||||||
const eidField = Object.entries(subscription.customFields || {}).find(
|
const eidField = Object.entries(subscription.customFields || {}).find(
|
||||||
([key, value]) => value && value.toString().includes(expectedEid)
|
([_key, value]) => value && value.toString().includes(expectedEid)
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -199,7 +202,7 @@ export class SimManagementService {
|
|||||||
// Check if any field contains the expected SIM number
|
// Check if any field contains the expected SIM number
|
||||||
const expectedSimNumber = "02000331144508";
|
const expectedSimNumber = "02000331144508";
|
||||||
const foundSimNumber = Object.entries(subscription.customFields || {}).find(
|
const foundSimNumber = Object.entries(subscription.customFields || {}).find(
|
||||||
([key, value]) => value && value.toString().includes(expectedSimNumber)
|
([_key, value]) => value && value.toString().includes(expectedSimNumber)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (foundSimNumber) {
|
if (foundSimNumber) {
|
||||||
|
|||||||
@ -19,13 +19,16 @@ export class SimUsageStoreService {
|
|||||||
async upsertToday(account: string, usageMb: number, date?: Date): Promise<void> {
|
async upsertToday(account: string, usageMb: number, date?: Date): Promise<void> {
|
||||||
const day = this.normalizeDate(date);
|
const day = this.normalizeDate(date);
|
||||||
try {
|
try {
|
||||||
await (this.prisma as any).simUsageDaily.upsert({
|
await this.prisma.simUsageDaily.upsert({
|
||||||
where: { account_date: { account, date: day } as any },
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error composite unique input type depends on Prisma schema
|
||||||
|
where: { account_date: { account, date: day } as unknown },
|
||||||
update: { usageMb },
|
update: { usageMb },
|
||||||
create: { account, date: day, usageMb },
|
create: { account, date: day, usageMb },
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
this.logger.error("Failed to upsert daily usage", { account, error: e?.message });
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
this.logger.error("Failed to upsert daily usage", { account, error: message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,7 +39,7 @@ export class SimUsageStoreService {
|
|||||||
const end = this.normalizeDate();
|
const end = this.normalizeDate();
|
||||||
const start = new Date(end);
|
const start = new Date(end);
|
||||||
start.setUTCDate(end.getUTCDate() - (days - 1));
|
start.setUTCDate(end.getUTCDate() - (days - 1));
|
||||||
const rows = (await (this.prisma as any).simUsageDaily.findMany({
|
const rows = (await this.prisma.simUsageDaily.findMany({
|
||||||
where: { account, date: { gte: start, lte: end } },
|
where: { account, date: { gte: start, lte: end } },
|
||||||
orderBy: { date: "desc" },
|
orderBy: { date: "desc" },
|
||||||
})) as Array<{ date: Date; usageMb: number }>;
|
})) as Array<{ date: Date; usageMb: number }>;
|
||||||
@ -46,7 +49,7 @@ export class SimUsageStoreService {
|
|||||||
async cleanupPreviousMonths(): Promise<number> {
|
async cleanupPreviousMonths(): Promise<number> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
const result = await (this.prisma as any).simUsageDaily.deleteMany({
|
const result = await this.prisma.simUsageDaily.deleteMany({
|
||||||
where: { date: { lt: firstOfMonth } },
|
where: { date: { lt: firstOfMonth } },
|
||||||
});
|
});
|
||||||
return result.count;
|
return result.count;
|
||||||
|
|||||||
@ -204,7 +204,7 @@ export class SubscriptionsController {
|
|||||||
async debugSimSubscription(
|
async debugSimSubscription(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
) {
|
): Promise<Record<string, unknown>> {
|
||||||
return this.simManagementService.debugSimSubscription(req.user.id, subscriptionId);
|
return this.simManagementService.debugSimSubscription(req.user.id, subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
106
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
106
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
@ -6,7 +6,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import {
|
import type {
|
||||||
FreebititConfig,
|
FreebititConfig,
|
||||||
FreebititAuthRequest,
|
FreebititAuthRequest,
|
||||||
FreebititAuthResponse,
|
FreebititAuthResponse,
|
||||||
@ -22,14 +22,13 @@ import {
|
|||||||
FreebititPlanChangeResponse,
|
FreebititPlanChangeResponse,
|
||||||
FreebititCancelPlanRequest,
|
FreebititCancelPlanRequest,
|
||||||
FreebititCancelPlanResponse,
|
FreebititCancelPlanResponse,
|
||||||
FreebititEsimReissueRequest,
|
|
||||||
FreebititEsimReissueResponse,
|
|
||||||
FreebititEsimAddAccountRequest,
|
FreebititEsimAddAccountRequest,
|
||||||
FreebititEsimAddAccountResponse,
|
FreebititEsimAddAccountResponse,
|
||||||
|
FreebititEsimAccountActivationRequest,
|
||||||
|
FreebititEsimAccountActivationResponse,
|
||||||
SimDetails,
|
SimDetails,
|
||||||
SimUsage,
|
SimUsage,
|
||||||
SimTopUpHistory,
|
SimTopUpHistory,
|
||||||
FreebititError,
|
|
||||||
FreebititAddSpecRequest,
|
FreebititAddSpecRequest,
|
||||||
FreebititAddSpecResponse,
|
FreebititAddSpecResponse,
|
||||||
} from "./interfaces/freebit.types";
|
} from "./interfaces/freebit.types";
|
||||||
@ -148,9 +147,12 @@ export class FreebititService {
|
|||||||
/**
|
/**
|
||||||
* Make authenticated API request with error handling
|
* Make authenticated API request with error handling
|
||||||
*/
|
*/
|
||||||
private async makeAuthenticatedRequest<T>(endpoint: string, data: any): Promise<T> {
|
private async makeAuthenticatedRequest<T extends { resultCode: string | number; status?: { message?: string; statusCode?: string | number } }>(
|
||||||
|
endpoint: string,
|
||||||
|
data: unknown
|
||||||
|
): Promise<T> {
|
||||||
const authKey = await this.getAuthKey();
|
const authKey = await this.getAuthKey();
|
||||||
const requestData = { ...data, authKey };
|
const requestData = { ...(data as Record<string, unknown>), authKey };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${this.config.baseUrl}${endpoint}`;
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
@ -164,10 +166,13 @@ export class FreebititService {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let bodySnippet: string | undefined;
|
let bodySnippet: string | undefined;
|
||||||
|
let text: string | null = null;
|
||||||
try {
|
try {
|
||||||
const text = await response.text();
|
text = await response.text();
|
||||||
bodySnippet = text ? text.slice(0, 500) : undefined;
|
} catch (_e) {
|
||||||
} catch {}
|
text = null;
|
||||||
|
}
|
||||||
|
bodySnippet = text ? text.slice(0, 500) : undefined;
|
||||||
this.logger.error("Freebit API non-OK response", {
|
this.logger.error("Freebit API non-OK response", {
|
||||||
endpoint,
|
endpoint,
|
||||||
url,
|
url,
|
||||||
@ -181,39 +186,39 @@ export class FreebititService {
|
|||||||
const responseData = (await response.json()) as T;
|
const responseData = (await response.json()) as T;
|
||||||
|
|
||||||
// Check for API-level errors
|
// Check for API-level errors
|
||||||
if (responseData && (responseData as any).resultCode !== "100") {
|
const rc = String(responseData?.resultCode ?? "");
|
||||||
const errorData = responseData as any;
|
if (rc !== "100") {
|
||||||
const errorMessage = errorData.status?.message || "Unknown error";
|
const errorMessage = String(responseData.status?.message ?? "Unknown error");
|
||||||
|
|
||||||
// Provide more specific error messages for common cases
|
// Provide more specific error messages for common cases
|
||||||
let userFriendlyMessage = `API Error: ${errorMessage}`;
|
let userFriendlyMessage = `API Error: ${errorMessage}`;
|
||||||
if (errorMessage === "NG") {
|
if (errorMessage === "NG") {
|
||||||
userFriendlyMessage = `Account not found or invalid in Freebit system. Please verify the account number exists and is properly configured.`;
|
userFriendlyMessage = `Account not found or invalid in Freebit system. Please verify the account number exists and is properly configured.`;
|
||||||
} else if (errorMessage.includes("auth") || errorMessage.includes("Auth")) {
|
} else if (errorMessage.toLowerCase().includes("auth")) {
|
||||||
userFriendlyMessage = `Authentication failed with Freebit API. Please check API credentials.`;
|
userFriendlyMessage = `Authentication failed with Freebit API. Please check API credentials.`;
|
||||||
} else if (errorMessage.includes("timeout") || errorMessage.includes("Timeout")) {
|
} else if (errorMessage.toLowerCase().includes("timeout")) {
|
||||||
userFriendlyMessage = `Request timeout to Freebit API. Please try again later.`;
|
userFriendlyMessage = `Request timeout to Freebit API. Please try again later.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error("Freebit API error response", {
|
this.logger.error("Freebit API error response", {
|
||||||
endpoint,
|
endpoint,
|
||||||
resultCode: errorData.resultCode,
|
resultCode: rc,
|
||||||
statusCode: errorData.status?.statusCode,
|
statusCode: responseData.status?.statusCode,
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
userFriendlyMessage,
|
userFriendlyMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new FreebititErrorImpl(
|
throw new FreebititErrorImpl(
|
||||||
userFriendlyMessage,
|
userFriendlyMessage,
|
||||||
errorData.resultCode,
|
rc,
|
||||||
errorData.status?.statusCode,
|
String(responseData.status?.statusCode ?? ""),
|
||||||
errorMessage
|
errorMessage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Freebit API Request Success", {
|
this.logger.debug("Freebit API Request Success", {
|
||||||
endpoint,
|
endpoint,
|
||||||
resultCode: (responseData as any).resultCode,
|
resultCode: rc,
|
||||||
});
|
});
|
||||||
|
|
||||||
return responseData;
|
return responseData;
|
||||||
@ -222,12 +227,9 @@ export class FreebititService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`Freebit API request failed: ${endpoint}`, {
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
error: (error as any).message,
|
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: message });
|
||||||
});
|
throw new InternalServerErrorException(`Freebit API request failed: ${message}`);
|
||||||
throw new InternalServerErrorException(
|
|
||||||
`Freebit API request failed: ${(error as any).message}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -264,7 +266,7 @@ export class FreebititService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let response: FreebititAccountDetailsResponse | undefined;
|
let response: FreebititAccountDetailsResponse | undefined;
|
||||||
let lastError: any;
|
let lastError: unknown;
|
||||||
for (const ep of candidates) {
|
for (const ep of candidates) {
|
||||||
try {
|
try {
|
||||||
if (ep !== candidates[0]) {
|
if (ep !== candidates[0]) {
|
||||||
@ -275,9 +277,9 @@ export class FreebititService {
|
|||||||
request
|
request
|
||||||
);
|
);
|
||||||
break; // success
|
break; // success
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
if (typeof err?.message === "string" && err.message.includes("HTTP 404")) {
|
if (err instanceof Error && err.message.includes("HTTP 404")) {
|
||||||
// try next candidate
|
// try next candidate
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -293,14 +295,40 @@ export class FreebititService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const datas = (response as any).responseDatas;
|
type AcctDetailItem = {
|
||||||
const list = Array.isArray(datas) ? datas : datas ? [datas] : [];
|
kind?: string;
|
||||||
|
account?: string | number;
|
||||||
|
state?: string;
|
||||||
|
startDate?: string | number;
|
||||||
|
relationCode?: string;
|
||||||
|
resultCode?: string | number;
|
||||||
|
planCode?: string;
|
||||||
|
iccid?: string | number;
|
||||||
|
imsi?: string | number;
|
||||||
|
eid?: string;
|
||||||
|
contractLine?: string;
|
||||||
|
size?: "standard" | "nano" | "micro" | "esim" | string;
|
||||||
|
sms?: number;
|
||||||
|
talk?: number;
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
|
quota?: number;
|
||||||
|
async?: { func: string; date: string | number };
|
||||||
|
voicemail?: number;
|
||||||
|
voiceMail?: number;
|
||||||
|
callwaiting?: number;
|
||||||
|
callWaiting?: number;
|
||||||
|
worldwing?: number;
|
||||||
|
worldWing?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const datas = response.responseDatas as unknown;
|
||||||
|
const list = Array.isArray(datas) ? (datas as AcctDetailItem[]) : datas ? [datas as AcctDetailItem] : [];
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
throw new BadRequestException("No SIM details found for this account");
|
throw new BadRequestException("No SIM details found for this account");
|
||||||
}
|
}
|
||||||
// Prefer the MVNO entry if present
|
// Prefer the MVNO entry if present
|
||||||
const mvno =
|
const mvno = list.find(d => String(d.kind ?? "").toUpperCase() === "MVNO") || list[0];
|
||||||
list.find((d: any) => (d.kind || "").toString().toUpperCase() === "MVNO") || list[0];
|
|
||||||
const simData = mvno;
|
const simData = mvno;
|
||||||
|
|
||||||
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
|
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
|
||||||
@ -348,11 +376,10 @@ export class FreebititService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return simDetails;
|
return simDetails;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
error: error.message,
|
this.logger.error(`Failed to get SIM details for account ${account}`, { error: message });
|
||||||
});
|
throw error as Error;
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -655,7 +682,7 @@ export class FreebititService {
|
|||||||
throw new BadRequestException("eSIM EID not found for this account");
|
throw new BadRequestException("eSIM EID not found for this account");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: import("./interfaces/freebit.types").FreebititEsimAccountActivationRequest = {
|
const payload: FreebititEsimAccountActivationRequest = {
|
||||||
authKey,
|
authKey,
|
||||||
aladinOperated: "20",
|
aladinOperated: "20",
|
||||||
createType: "reissue",
|
createType: "reissue",
|
||||||
@ -686,8 +713,7 @@ export class FreebititService {
|
|||||||
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
|
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data =
|
const data = (await response.json()) as FreebititEsimAccountActivationResponse;
|
||||||
(await response.json()) as import("./interfaces/freebit.types").FreebititEsimAccountActivationResponse;
|
|
||||||
const rc =
|
const rc =
|
||||||
typeof data.resultCode === "number" ? String(data.resultCode) : data.resultCode || "";
|
typeof data.resultCode === "number" ? String(data.resultCode) : data.resultCode || "";
|
||||||
if (rc !== "100") {
|
if (rc !== "100") {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export interface FreebititAccountDetailsRequest {
|
|||||||
authKey: string;
|
authKey: string;
|
||||||
version?: string | number; // Docs recommend "2"
|
version?: string | number; // Docs recommend "2"
|
||||||
requestDatas: Array<{
|
requestDatas: Array<{
|
||||||
kind: "MASTER" | "MVNO" | string;
|
kind: string;
|
||||||
account?: string | number;
|
account?: string | number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@ -33,9 +33,9 @@ export interface FreebititAccountDetailsResponse {
|
|||||||
// Docs show this can be an array (MASTER + MVNO) or a single object for MVNO
|
// Docs show this can be an array (MASTER + MVNO) or a single object for MVNO
|
||||||
responseDatas:
|
responseDatas:
|
||||||
| {
|
| {
|
||||||
kind: "MASTER" | "MVNO" | string;
|
kind: string;
|
||||||
account: string | number;
|
account: string | number;
|
||||||
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
|
state: string;
|
||||||
startDate?: string | number;
|
startDate?: string | number;
|
||||||
relationCode?: string;
|
relationCode?: string;
|
||||||
resultCode?: string | number;
|
resultCode?: string | number;
|
||||||
@ -44,21 +44,21 @@ export interface FreebititAccountDetailsResponse {
|
|||||||
imsi?: string | number;
|
imsi?: string | number;
|
||||||
eid?: string;
|
eid?: string;
|
||||||
contractLine?: string;
|
contractLine?: string;
|
||||||
size?: "standard" | "nano" | "micro" | "esim" | string;
|
size?: string;
|
||||||
sms?: number; // 10=active, 20=inactive
|
sms?: number; // 10=active, 20=inactive
|
||||||
talk?: number; // 10=active, 20=inactive
|
talk?: number; // 10=active, 20=inactive
|
||||||
ipv4?: string;
|
ipv4?: string;
|
||||||
ipv6?: string;
|
ipv6?: string;
|
||||||
quota?: number; // Remaining quota (units vary by env)
|
quota?: number; // Remaining quota (units vary by env)
|
||||||
async?: {
|
async?: {
|
||||||
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
|
func: string;
|
||||||
date: string | number;
|
date: string | number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| Array<{
|
| Array<{
|
||||||
kind: "MASTER" | "MVNO" | string;
|
kind: string;
|
||||||
account: string | number;
|
account: string | number;
|
||||||
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
|
state: string;
|
||||||
startDate?: string | number;
|
startDate?: string | number;
|
||||||
relationCode?: string;
|
relationCode?: string;
|
||||||
resultCode?: string | number;
|
resultCode?: string | number;
|
||||||
@ -67,14 +67,14 @@ export interface FreebititAccountDetailsResponse {
|
|||||||
imsi?: string | number;
|
imsi?: string | number;
|
||||||
eid?: string;
|
eid?: string;
|
||||||
contractLine?: string;
|
contractLine?: string;
|
||||||
size?: "standard" | "nano" | "micro" | "esim" | string;
|
size?: string;
|
||||||
sms?: number;
|
sms?: number;
|
||||||
talk?: number;
|
talk?: number;
|
||||||
ipv4?: string;
|
ipv4?: string;
|
||||||
ipv6?: string;
|
ipv6?: string;
|
||||||
quota?: number;
|
quota?: number;
|
||||||
async?: {
|
async?: {
|
||||||
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
|
func: string;
|
||||||
date: string | number;
|
date: string | number;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
@ -244,13 +244,13 @@ export interface FreebititEsimAccountActivationRequest {
|
|||||||
aladinOperated: string; // '10' issue, '20' no-issue
|
aladinOperated: string; // '10' issue, '20' no-issue
|
||||||
masterAccount?: string;
|
masterAccount?: string;
|
||||||
masterPassword?: string;
|
masterPassword?: string;
|
||||||
createType: "new" | "reissue" | "exchange" | string;
|
createType: string;
|
||||||
eid?: string; // required for reissue/exchange per business rules
|
eid?: string; // required for reissue/exchange per business rules
|
||||||
account: string; // MSISDN
|
account: string; // MSISDN
|
||||||
simkind: "esim" | string;
|
simkind: string;
|
||||||
repAccount?: string;
|
repAccount?: string;
|
||||||
size?: string;
|
size?: string;
|
||||||
addKind?: "N" | "R" | string; // e.g., 'R' for reissue
|
addKind?: string; // e.g., 'R' for reissue
|
||||||
oldEid?: string;
|
oldEid?: string;
|
||||||
oldProductNumber?: string;
|
oldProductNumber?: string;
|
||||||
mnp?: {
|
mnp?: {
|
||||||
@ -272,7 +272,7 @@ export interface FreebititEsimAccountActivationRequest {
|
|||||||
|
|
||||||
export interface FreebititEsimAccountActivationResponse {
|
export interface FreebititEsimAccountActivationResponse {
|
||||||
resultCode: number | string;
|
resultCode: number | string;
|
||||||
status?: any;
|
status?: unknown;
|
||||||
statusCode?: string;
|
statusCode?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,15 +5,7 @@ import { Invoice, InvoiceList } from "@customer-portal/shared";
|
|||||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||||
import {
|
import { WhmcsGetInvoicesParams } from "../types/whmcs-api.types";
|
||||||
WhmcsGetInvoicesParams,
|
|
||||||
WhmcsCreateInvoiceParams,
|
|
||||||
WhmcsCreateInvoiceResponse,
|
|
||||||
WhmcsUpdateInvoiceParams,
|
|
||||||
WhmcsUpdateInvoiceResponse,
|
|
||||||
WhmcsCapturePaymentParams,
|
|
||||||
WhmcsCapturePaymentResponse,
|
|
||||||
} from "../types/whmcs-api.types";
|
|
||||||
|
|
||||||
export interface InvoiceFilters {
|
export interface InvoiceFilters {
|
||||||
status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
|
status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
/* eslint-env node */
|
||||||
|
/* eslint-disable no-console */
|
||||||
// Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors
|
// Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors
|
||||||
import { mkdirSync, existsSync, writeFileSync } from "fs";
|
import { mkdirSync, existsSync, writeFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|||||||
@ -457,7 +457,7 @@ export default function SimPlansPage() {
|
|||||||
<div className="font-medium text-blue-900">Contract Period</div>
|
<div className="font-medium text-blue-900">Contract Period</div>
|
||||||
<p className="text-blue-800">
|
<p className="text-blue-800">
|
||||||
Minimum 3 full billing months required. First month (sign-up to end of month) is
|
Minimum 3 full billing months required. First month (sign-up to end of month) is
|
||||||
free and doesn't count toward contract.
|
free and doesn't count toward contract.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -12,8 +12,6 @@ import {
|
|||||||
CubeIcon,
|
CubeIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
PlusIcon,
|
|
||||||
BoltIcon,
|
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
PhoneIcon,
|
PhoneIcon,
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export default function SimCancelPage() {
|
|||||||
try {
|
try {
|
||||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
||||||
setMessage("SIM service cancelled successfully");
|
setMessage("SIM service cancelled successfully");
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to cancel SIM service");
|
setError(e instanceof Error ? e.message : "Failed to cancel SIM service");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -62,7 +62,7 @@ export default function SimCancelPage() {
|
|||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={() => void submit()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
|
className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export default function SimChangePlanPage() {
|
|||||||
newPlanCode,
|
newPlanCode,
|
||||||
});
|
});
|
||||||
setMessage("Plan change submitted successfully");
|
setMessage("Plan change submitted successfully");
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to change plan");
|
setError(e instanceof Error ? e.message : "Failed to change plan");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -79,7 +79,7 @@ export default function SimChangePlanPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={submit} className="space-y-6">
|
<form onSubmit={(e) => void submit(e)} className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export default function SimTopUpPage() {
|
|||||||
quotaMb: getCurrentAmountMb(),
|
quotaMb: getCurrentAmountMb(),
|
||||||
});
|
});
|
||||||
setMessage(`Successfully topped up ${gbAmount} GB for ¥${calculateCost().toLocaleString()}`);
|
setMessage(`Successfully topped up ${gbAmount} GB for ¥${calculateCost().toLocaleString()}`);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to submit top-up");
|
setError(e instanceof Error ? e.message : "Failed to submit top-up");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -83,7 +83,7 @@ export default function SimTopUpPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-6">
|
||||||
{/* Amount Input */}
|
{/* Amount Input */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export function ChangePlanModal({
|
|||||||
newPlanCode: newPlanCode,
|
newPlanCode: newPlanCode,
|
||||||
});
|
});
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
onError(e instanceof Error ? e.message : "Failed to change plan");
|
onError(e instanceof Error ? e.message : "Failed to change plan");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -103,7 +103,7 @@ export function ChangePlanModal({
|
|||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={() => void submit()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -242,8 +242,8 @@ export function DataUsageChart({
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-red-800">High Usage Warning</h4>
|
<h4 className="text-sm font-medium text-red-800">High Usage Warning</h4>
|
||||||
<p className="text-sm text-red-700 mt-1">
|
<p className="text-sm text-red-700 mt-1">
|
||||||
You've used {usagePercentage.toFixed(1)}% of your data quota. Consider topping up
|
You have used {usagePercentage.toFixed(1)}% of your data quota. Consider topping
|
||||||
to avoid service interruption.
|
up to avoid service interruption.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -257,8 +257,8 @@ export function DataUsageChart({
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-yellow-800">Usage Notice</h4>
|
<h4 className="text-sm font-medium text-yellow-800">Usage Notice</h4>
|
||||||
<p className="text-sm text-yellow-700 mt-1">
|
<p className="text-sm text-yellow-700 mt-1">
|
||||||
You've used {usagePercentage.toFixed(1)}% of your data quota. Consider monitoring
|
You have used {usagePercentage.toFixed(1)}% of your data quota. Consider
|
||||||
your usage.
|
monitoring your usage.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export function SimActions({
|
|||||||
setSuccess("eSIM profile reissued successfully");
|
setSuccess("eSIM profile reissued successfully");
|
||||||
setShowReissueConfirm(false);
|
setShowReissueConfirm(false);
|
||||||
onReissueSuccess?.();
|
onReissueSuccess?.();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "Failed to reissue eSIM profile");
|
setError(error instanceof Error ? error.message : "Failed to reissue eSIM profile");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
@ -80,7 +80,7 @@ export function SimActions({
|
|||||||
setSuccess("SIM service cancelled successfully");
|
setSuccess("SIM service cancelled successfully");
|
||||||
setShowCancelConfirm(false);
|
setShowCancelConfirm(false);
|
||||||
onCancelSuccess?.();
|
onCancelSuccess?.();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
setError(error instanceof Error ? error.message : "Failed to cancel SIM service");
|
setError(error instanceof Error ? error.message : "Failed to cancel SIM service");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(null);
|
setLoading(null);
|
||||||
@ -399,7 +399,7 @@ export function SimActions({
|
|||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleReissueEsim}
|
onClick={() => void handleReissueEsim()}
|
||||||
disabled={loading === "reissue"}
|
disabled={loading === "reissue"}
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@ -449,7 +449,7 @@ export function SimActions({
|
|||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCancelSim}
|
onClick={() => void handleCancelSim()}
|
||||||
disabled={loading === "cancel"}
|
disabled={loading === "cancel"}
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -63,22 +63,24 @@ export function SimFeatureToggles({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
try {
|
try {
|
||||||
const featurePayload: any = {};
|
const featurePayload: {
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: "4G" | "5G";
|
||||||
|
} = {};
|
||||||
if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm;
|
if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm;
|
||||||
if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw;
|
if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw;
|
||||||
if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir;
|
if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir;
|
||||||
if (nt !== initial.nt) featurePayload.networkType = nt;
|
if (nt !== initial.nt) featurePayload.networkType = nt;
|
||||||
|
|
||||||
if (Object.keys(featurePayload).length > 0) {
|
if (Object.keys(featurePayload).length > 0) {
|
||||||
await authenticatedApi.post(
|
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/features`, featurePayload);
|
||||||
`/subscriptions/${subscriptionId}/sim/features`,
|
|
||||||
featurePayload
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess("Changes submitted successfully");
|
setSuccess("Changes submitted successfully");
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to submit changes");
|
setError(e instanceof Error ? e.message : "Failed to submit changes");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -347,7 +349,7 @@ export function SimFeatureToggles({
|
|||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={applyChanges}
|
onClick={() => void applyChanges()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 inline-flex items-center justify-center px-6 py-3 border border-transparent rounded-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
className="flex-1 inline-flex items-center justify-center px-6 py-3 border border-transparent rounded-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||||
>
|
>
|
||||||
@ -389,7 +391,7 @@ export function SimFeatureToggles({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={reset}
|
onClick={() => reset()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -36,12 +36,14 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
}>(`/subscriptions/${subscriptionId}/sim`);
|
}>(`/subscriptions/${subscriptionId}/sim`);
|
||||||
|
|
||||||
setSimInfo(data);
|
setSimInfo(data);
|
||||||
} catch (error: any) {
|
} catch (err: unknown) {
|
||||||
if (error.status === 400) {
|
const hasStatus = (v: unknown): v is { status: number } =>
|
||||||
|
typeof v === 'object' && v !== null && 'status' in v && typeof (v as { status: unknown }).status === 'number';
|
||||||
|
if (hasStatus(err) && err.status === 400) {
|
||||||
// Not a SIM subscription - this component shouldn't be shown
|
// Not a SIM subscription - this component shouldn't be shown
|
||||||
setError("This subscription is not a SIM service");
|
setError("This subscription is not a SIM service");
|
||||||
} else {
|
} else {
|
||||||
setError(error instanceof Error ? error.message : "Failed to load SIM information");
|
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -49,17 +51,17 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSimInfo();
|
void fetchSimInfo();
|
||||||
}, [subscriptionId]);
|
}, [subscriptionId]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchSimInfo();
|
void fetchSimInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleActionSuccess = () => {
|
const handleActionSuccess = () => {
|
||||||
// Refresh SIM info after any successful action
|
// Refresh SIM info after any successful action
|
||||||
fetchSimInfo();
|
void fetchSimInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody);
|
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody);
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
onError(error instanceof Error ? error.message : "Failed to top up SIM");
|
onError(error instanceof Error ? error.message : "Failed to top up SIM");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -87,7 +87,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={(e) => void handleSubmit(e)}>
|
||||||
{/* Amount Input */}
|
{/* Amount Input */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user