Refactor code for improved readability and consistency across components
- Standardized import statements and formatting in various files for better code clarity. - Enhanced error messages and logging for improved debugging and user experience. - Adjusted whitespace and line breaks in multiple components to follow best practices. - Updated environment variable handling and configuration for consistency across services.
This commit is contained in:
parent
425ef83dba
commit
05817e8c67
@ -30,9 +30,12 @@ export class TokenBlacklistService {
|
|||||||
const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));
|
const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));
|
||||||
await this.redis.setex(`blacklist:${token}`, defaultTtl, "1");
|
await this.redis.setex(`blacklist:${token}`, defaultTtl, "1");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn("Failed to write token to Redis blacklist; proceeding without persistence", {
|
this.logger.warn(
|
||||||
error: err instanceof Error ? err.message : String(err),
|
"Failed to write token to Redis blacklist; proceeding without persistence",
|
||||||
});
|
{
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,9 +45,7 @@ export const envSchema = z.object({
|
|||||||
|
|
||||||
// Salesforce Platform Events (Async Provisioning)
|
// Salesforce Platform Events (Async Provisioning)
|
||||||
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"),
|
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||||
SF_PROVISION_EVENT_CHANNEL: z
|
SF_PROVISION_EVENT_CHANNEL: z.string().default("/event/Order_Fulfilment_Requested__e"),
|
||||||
.string()
|
|
||||||
.default("/event/Order_Fulfilment_Requested__e"),
|
|
||||||
SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"),
|
SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"),
|
||||||
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),
|
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),
|
||||||
SF_PUBSUB_NUM_REQUESTED: z.string().default("50"),
|
SF_PUBSUB_NUM_REQUESTED: z.string().default("50"),
|
||||||
|
|||||||
@ -188,13 +188,11 @@ export function getSalesforceFieldMap(): SalesforceFieldMap {
|
|||||||
|
|
||||||
// Billing address snapshot fields — single source of truth: Billing* fields on Order
|
// Billing address snapshot fields — single source of truth: Billing* fields on Order
|
||||||
billing: {
|
billing: {
|
||||||
|
|
||||||
street: process.env.ORDER_BILLING_STREET_FIELD || "BillingStreet",
|
street: process.env.ORDER_BILLING_STREET_FIELD || "BillingStreet",
|
||||||
city: process.env.ORDER_BILLING_CITY_FIELD || "BillingCity",
|
city: process.env.ORDER_BILLING_CITY_FIELD || "BillingCity",
|
||||||
state: process.env.ORDER_BILLING_STATE_FIELD || "BillingState",
|
state: process.env.ORDER_BILLING_STATE_FIELD || "BillingState",
|
||||||
postalCode: process.env.ORDER_BILLING_POSTAL_CODE_FIELD || "BillingPostalCode",
|
postalCode: process.env.ORDER_BILLING_POSTAL_CODE_FIELD || "BillingPostalCode",
|
||||||
country: process.env.ORDER_BILLING_COUNTRY_FIELD || "BillingCountry",
|
country: process.env.ORDER_BILLING_COUNTRY_FIELD || "BillingCountry",
|
||||||
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderItem: {
|
orderItem: {
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
import { Injectable, Inject, BadRequestException, NotFoundException } 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";
|
||||||
import { MappingsService } from '../mappings/mappings.service';
|
import { MappingsService } from "../mappings/mappings.service";
|
||||||
import { SubscriptionsService } from './subscriptions.service';
|
import { SubscriptionsService } from "./subscriptions.service";
|
||||||
import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types';
|
import { SimDetails, SimUsage, SimTopUpHistory } from "../vendors/freebit/interfaces/freebit.types";
|
||||||
import { SimUsageStoreService } from './sim-usage-store.service';
|
import { SimUsageStoreService } from "./sim-usage-store.service";
|
||||||
import { getErrorMessage } from '../common/utils/error.util';
|
import { getErrorMessage } from "../common/utils/error.util";
|
||||||
|
|
||||||
export interface SimTopUpRequest {
|
export interface SimTopUpRequest {
|
||||||
quotaMb: number;
|
quotaMb: number;
|
||||||
@ -22,14 +22,14 @@ export interface SimCancelRequest {
|
|||||||
|
|
||||||
export interface SimTopUpHistoryRequest {
|
export interface SimTopUpHistoryRequest {
|
||||||
fromDate: string; // YYYYMMDD
|
fromDate: string; // YYYYMMDD
|
||||||
toDate: string; // YYYYMMDD
|
toDate: string; // YYYYMMDD
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimFeaturesUpdateRequest {
|
export interface SimFeaturesUpdateRequest {
|
||||||
voiceMailEnabled?: boolean;
|
voiceMailEnabled?: boolean;
|
||||||
callWaitingEnabled?: boolean;
|
callWaitingEnabled?: boolean;
|
||||||
internationalRoamingEnabled?: boolean;
|
internationalRoamingEnabled?: boolean;
|
||||||
networkType?: '4G' | '5G';
|
networkType?: "4G" | "5G";
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -40,7 +40,7 @@ export class SimManagementService {
|
|||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsService: SubscriptionsService,
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
private readonly usageStore: SimUsageStoreService,
|
private readonly usageStore: SimUsageStoreService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,37 +48,43 @@ export class SimManagementService {
|
|||||||
*/
|
*/
|
||||||
async debugSimSubscription(userId: string, subscriptionId: number): Promise<any> {
|
async debugSimSubscription(userId: string, subscriptionId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId);
|
const subscription = await this.subscriptionsService.getSubscriptionById(
|
||||||
|
userId,
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
|
||||||
// Check for specific SIM data
|
// Check for specific SIM data
|
||||||
const expectedSimNumber = '02000331144508';
|
const expectedSimNumber = "02000331144508";
|
||||||
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 {
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
productName: subscription.productName,
|
productName: subscription.productName,
|
||||||
domain: subscription.domain,
|
domain: subscription.domain,
|
||||||
orderNumber: subscription.orderNumber,
|
orderNumber: subscription.orderNumber,
|
||||||
customFields: subscription.customFields,
|
customFields: subscription.customFields,
|
||||||
isSimService: subscription.productName.toLowerCase().includes('sim') ||
|
isSimService:
|
||||||
subscription.groupName?.toLowerCase().includes('sim'),
|
subscription.productName.toLowerCase().includes("sim") ||
|
||||||
|
subscription.groupName?.toLowerCase().includes("sim"),
|
||||||
groupName: subscription.groupName,
|
groupName: subscription.groupName,
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
// Specific SIM data checks
|
// Specific SIM data checks
|
||||||
expectedSimNumber,
|
expectedSimNumber,
|
||||||
expectedEid,
|
expectedEid,
|
||||||
foundSimNumber: simNumberField ? { field: simNumberField[0], value: simNumberField[1] } : null,
|
foundSimNumber: simNumberField
|
||||||
|
? { field: simNumberField[0], value: simNumberField[1] }
|
||||||
|
: null,
|
||||||
foundEid: eidField ? { field: eidField[0], value: eidField[1] } : null,
|
foundEid: eidField ? { field: eidField[0], value: eidField[1] } : null,
|
||||||
allCustomFieldKeys: Object.keys(subscription.customFields || {}),
|
allCustomFieldKeys: Object.keys(subscription.customFields || {}),
|
||||||
allCustomFieldValues: subscription.customFields
|
allCustomFieldValues: subscription.customFields,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to debug subscription ${subscriptionId}`, {
|
this.logger.error(`Failed to debug subscription ${subscriptionId}`, {
|
||||||
@ -91,44 +97,79 @@ export class SimManagementService {
|
|||||||
/**
|
/**
|
||||||
* Check if a subscription is a SIM service
|
* Check if a subscription is a SIM service
|
||||||
*/
|
*/
|
||||||
private async validateSimSubscription(userId: string, subscriptionId: number): Promise<{ account: string }> {
|
private async validateSimSubscription(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<{ account: string }> {
|
||||||
try {
|
try {
|
||||||
// Get subscription details to verify it's a SIM service
|
// Get subscription details to verify it's a SIM service
|
||||||
const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId);
|
const subscription = await this.subscriptionsService.getSubscriptionById(
|
||||||
|
userId,
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
|
||||||
// Check if this is a SIM service (you may need to adjust this logic based on your product naming)
|
// Check if this is a SIM service (you may need to adjust this logic based on your product naming)
|
||||||
const isSimService = subscription.productName.toLowerCase().includes('sim') ||
|
const isSimService =
|
||||||
subscription.groupName?.toLowerCase().includes('sim');
|
subscription.productName.toLowerCase().includes("sim") ||
|
||||||
|
subscription.groupName?.toLowerCase().includes("sim");
|
||||||
|
|
||||||
if (!isSimService) {
|
if (!isSimService) {
|
||||||
throw new BadRequestException('This subscription is not a SIM service');
|
throw new BadRequestException("This subscription is not a SIM service");
|
||||||
}
|
}
|
||||||
|
|
||||||
// For SIM services, the account identifier (phone number) can be stored in multiple places
|
// For SIM services, the account identifier (phone number) can be stored in multiple places
|
||||||
let account = '';
|
let account = "";
|
||||||
|
|
||||||
// 1. Try domain field first
|
// 1. Try domain field first
|
||||||
if (subscription.domain && subscription.domain.trim()) {
|
if (subscription.domain && subscription.domain.trim()) {
|
||||||
account = subscription.domain.trim();
|
account = subscription.domain.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. If no domain, check custom fields for phone number/MSISDN
|
// 2. If no domain, check custom fields for phone number/MSISDN
|
||||||
if (!account && subscription.customFields) {
|
if (!account && subscription.customFields) {
|
||||||
// Common field names for SIM phone numbers in WHMCS
|
// Common field names for SIM phone numbers in WHMCS
|
||||||
const phoneFields = [
|
const phoneFields = [
|
||||||
'phone', 'msisdn', 'phonenumber', 'phone_number', 'mobile', 'sim_phone',
|
"phone",
|
||||||
'Phone Number', 'MSISDN', 'Phone', 'Mobile', 'SIM Phone', 'PhoneNumber',
|
"msisdn",
|
||||||
'phone_number', 'mobile_number', 'sim_number', 'account_number',
|
"phonenumber",
|
||||||
'Account Number', 'SIM Account', 'Phone Number (SIM)', 'Mobile Number',
|
"phone_number",
|
||||||
|
"mobile",
|
||||||
|
"sim_phone",
|
||||||
|
"Phone Number",
|
||||||
|
"MSISDN",
|
||||||
|
"Phone",
|
||||||
|
"Mobile",
|
||||||
|
"SIM Phone",
|
||||||
|
"PhoneNumber",
|
||||||
|
"phone_number",
|
||||||
|
"mobile_number",
|
||||||
|
"sim_number",
|
||||||
|
"account_number",
|
||||||
|
"Account Number",
|
||||||
|
"SIM Account",
|
||||||
|
"Phone Number (SIM)",
|
||||||
|
"Mobile Number",
|
||||||
// Specific field names that might contain the SIM number
|
// Specific field names that might contain the SIM number
|
||||||
'SIM Number', 'SIM_Number', 'sim_number', 'SIM_Phone_Number',
|
"SIM Number",
|
||||||
'Phone_Number_SIM', 'Mobile_SIM_Number', 'SIM_Account_Number',
|
"SIM_Number",
|
||||||
'ICCID', 'iccid', 'IMSI', 'imsi', 'EID', 'eid',
|
"sim_number",
|
||||||
|
"SIM_Phone_Number",
|
||||||
|
"Phone_Number_SIM",
|
||||||
|
"Mobile_SIM_Number",
|
||||||
|
"SIM_Account_Number",
|
||||||
|
"ICCID",
|
||||||
|
"iccid",
|
||||||
|
"IMSI",
|
||||||
|
"imsi",
|
||||||
|
"EID",
|
||||||
|
"eid",
|
||||||
// Additional variations
|
// Additional variations
|
||||||
'02000331144508', // Direct match for your specific SIM number
|
"02000331144508", // Direct match for your specific SIM number
|
||||||
'SIM_Data', 'SIM_Info', 'SIM_Details'
|
"SIM_Data",
|
||||||
|
"SIM_Info",
|
||||||
|
"SIM_Details",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const fieldName of phoneFields) {
|
for (const fieldName of phoneFields) {
|
||||||
if (subscription.customFields[fieldName]) {
|
if (subscription.customFields[fieldName]) {
|
||||||
account = subscription.customFields[fieldName];
|
account = subscription.customFields[fieldName];
|
||||||
@ -136,35 +177,40 @@ export class SimManagementService {
|
|||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
fieldName,
|
fieldName,
|
||||||
account
|
account,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still no account found, log all available custom fields for debugging
|
// If still no account found, log all available custom fields for debugging
|
||||||
if (!account) {
|
if (!account) {
|
||||||
this.logger.warn(`No SIM account found in custom fields for subscription ${subscriptionId}`, {
|
this.logger.warn(
|
||||||
userId,
|
`No SIM account found in custom fields for subscription ${subscriptionId}`,
|
||||||
subscriptionId,
|
{
|
||||||
availableFields: Object.keys(subscription.customFields),
|
userId,
|
||||||
customFields: subscription.customFields,
|
subscriptionId,
|
||||||
searchedFields: phoneFields
|
availableFields: Object.keys(subscription.customFields),
|
||||||
});
|
customFields: subscription.customFields,
|
||||||
|
searchedFields: phoneFields,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 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) {
|
||||||
this.logger.log(`Found expected SIM number ${expectedSimNumber} in field '${foundSimNumber[0]}': ${foundSimNumber[1]}`);
|
this.logger.log(
|
||||||
|
`Found expected SIM number ${expectedSimNumber} in field '${foundSimNumber[0]}': ${foundSimNumber[1]}`
|
||||||
|
);
|
||||||
account = foundSimNumber[1].toString();
|
account = foundSimNumber[1].toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. If still no account, check if subscription ID looks like a phone number
|
// 3. If still no account, check if subscription ID looks like a phone number
|
||||||
if (!account && subscription.orderNumber) {
|
if (!account && subscription.orderNumber) {
|
||||||
const orderNum = subscription.orderNumber.toString();
|
const orderNum = subscription.orderNumber.toString();
|
||||||
@ -172,25 +218,28 @@ export class SimManagementService {
|
|||||||
account = orderNum;
|
account = orderNum;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Final fallback - for testing, use the known test SIM number
|
// 4. Final fallback - for testing, use the known test SIM number
|
||||||
if (!account) {
|
if (!account) {
|
||||||
// Use the specific test SIM number that should exist in the test environment
|
// Use the specific test SIM number that should exist in the test environment
|
||||||
account = '02000331144508';
|
account = "02000331144508";
|
||||||
|
|
||||||
this.logger.warn(`No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`, {
|
this.logger.warn(
|
||||||
userId,
|
`No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`,
|
||||||
subscriptionId,
|
{
|
||||||
productName: subscription.productName,
|
userId,
|
||||||
domain: subscription.domain,
|
subscriptionId,
|
||||||
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
|
productName: subscription.productName,
|
||||||
note: 'Using known test SIM number 02000331144508 - should exist in Freebit test environment'
|
domain: subscription.domain,
|
||||||
});
|
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
|
||||||
|
note: "Using known test SIM number 02000331144508 - should exist in Freebit test environment",
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the account format (remove hyphens, spaces, etc.)
|
// Clean up the account format (remove hyphens, spaces, etc.)
|
||||||
account = account.replace(/[-\s()]/g, '');
|
account = account.replace(/[-\s()]/g, "");
|
||||||
|
|
||||||
// Skip phone number format validation for testing
|
// Skip phone number format validation for testing
|
||||||
// In production, you might want to add validation back:
|
// In production, you might want to add validation back:
|
||||||
// const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0
|
// const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0
|
||||||
@ -198,19 +247,22 @@ export class SimManagementService {
|
|||||||
// throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`);
|
// throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`);
|
||||||
// }
|
// }
|
||||||
// account = cleanAccount;
|
// account = cleanAccount;
|
||||||
|
|
||||||
this.logger.log(`Using SIM account for testing: ${account}`, {
|
this.logger.log(`Using SIM account for testing: ${account}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
account,
|
account,
|
||||||
note: 'Phone number format validation skipped for testing'
|
note: "Phone number format validation skipped for testing",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { account };
|
return { account };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`, {
|
this.logger.error(
|
||||||
error: getErrorMessage(error),
|
`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`,
|
||||||
});
|
{
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
}
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -221,9 +273,9 @@ export class SimManagementService {
|
|||||||
async getSimDetails(userId: string, subscriptionId: number): Promise<SimDetails> {
|
async getSimDetails(userId: string, subscriptionId: number): Promise<SimDetails> {
|
||||||
try {
|
try {
|
||||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
const simDetails = await this.freebititService.getSimDetails(account);
|
const simDetails = await this.freebititService.getSimDetails(account);
|
||||||
|
|
||||||
this.logger.log(`Retrieved SIM details for subscription ${subscriptionId}`, {
|
this.logger.log(`Retrieved SIM details for subscription ${subscriptionId}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -248,7 +300,7 @@ export class SimManagementService {
|
|||||||
async getSimUsage(userId: string, subscriptionId: number): Promise<SimUsage> {
|
async getSimUsage(userId: string, subscriptionId: number): Promise<SimUsage> {
|
||||||
try {
|
try {
|
||||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
const simUsage = await this.freebititService.getSimUsage(account);
|
const simUsage = await this.freebititService.getSimUsage(account);
|
||||||
|
|
||||||
// Persist today's usage for monthly charts and cleanup previous months
|
// Persist today's usage for monthly charts and cleanup previous months
|
||||||
@ -264,9 +316,12 @@ export class SimManagementService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn('SIM usage persistence failed (non-fatal)', { account, error: getErrorMessage(e) });
|
this.logger.warn("SIM usage persistence failed (non-fatal)", {
|
||||||
|
account,
|
||||||
|
error: getErrorMessage(e),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, {
|
this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -292,26 +347,28 @@ export class SimManagementService {
|
|||||||
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
// Validate quota amount
|
// Validate quota amount
|
||||||
if (request.quotaMb <= 0 || request.quotaMb > 100000) {
|
if (request.quotaMb <= 0 || request.quotaMb > 100000) {
|
||||||
throw new BadRequestException('Quota must be between 1MB and 100GB');
|
throw new BadRequestException("Quota must be between 1MB and 100GB");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate cost: 1GB = 500 JPY (rounded up to nearest GB)
|
// Calculate cost: 1GB = 500 JPY (rounded up to nearest GB)
|
||||||
const quotaGb = request.quotaMb / 1000;
|
const quotaGb = request.quotaMb / 1000;
|
||||||
const units = Math.ceil(quotaGb);
|
const units = Math.ceil(quotaGb);
|
||||||
const costJpy = units * 500;
|
const costJpy = units * 500;
|
||||||
|
|
||||||
// Validate quota against Freebit API limits (100MB - 51200MB)
|
// Validate quota against Freebit API limits (100MB - 51200MB)
|
||||||
if (request.quotaMb < 100 || request.quotaMb > 51200) {
|
if (request.quotaMb < 100 || request.quotaMb > 51200) {
|
||||||
throw new BadRequestException('Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility');
|
throw new BadRequestException(
|
||||||
|
"Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get client mapping for WHMCS
|
// Get client mapping for WHMCS
|
||||||
const mapping = await this.mappingsService.findByUserId(userId);
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
if (!mapping?.whmcsClientId) {
|
if (!mapping?.whmcsClientId) {
|
||||||
throw new BadRequestException('WHMCS client mapping not found');
|
throw new BadRequestException("WHMCS client mapping not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
|
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
|
||||||
@ -328,7 +385,7 @@ export class SimManagementService {
|
|||||||
clientId: mapping.whmcsClientId,
|
clientId: mapping.whmcsClientId,
|
||||||
description: `SIM Data Top-up: ${units}GB for ${account}`,
|
description: `SIM Data Top-up: ${units}GB for ${account}`,
|
||||||
amount: costJpy,
|
amount: costJpy,
|
||||||
currency: 'JPY',
|
currency: "JPY",
|
||||||
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
||||||
notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`,
|
notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`,
|
||||||
});
|
});
|
||||||
@ -349,7 +406,7 @@ export class SimManagementService {
|
|||||||
const paymentResult = await this.whmcsService.capturePayment({
|
const paymentResult = await this.whmcsService.capturePayment({
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
amount: costJpy,
|
amount: costJpy,
|
||||||
currency: 'JPY',
|
currency: "JPY",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!paymentResult.success) {
|
if (!paymentResult.success) {
|
||||||
@ -363,19 +420,19 @@ export class SimManagementService {
|
|||||||
try {
|
try {
|
||||||
await this.whmcsService.updateInvoice({
|
await this.whmcsService.updateInvoice({
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
status: 'Cancelled',
|
status: "Cancelled",
|
||||||
notes: `Payment capture failed: ${paymentResult.error}. Invoice cancelled automatically.`
|
notes: `Payment capture failed: ${paymentResult.error}. Invoice cancelled automatically.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Cancelled invoice ${invoice.id} due to payment failure`, {
|
this.logger.log(`Cancelled invoice ${invoice.id} due to payment failure`, {
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
reason: 'Payment capture failed'
|
reason: "Payment capture failed",
|
||||||
});
|
});
|
||||||
} catch (cancelError) {
|
} catch (cancelError) {
|
||||||
this.logger.error(`Failed to cancel invoice ${invoice.id} after payment failure`, {
|
this.logger.error(`Failed to cancel invoice ${invoice.id} after payment failure`, {
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
cancelError: getErrorMessage(cancelError),
|
cancelError: getErrorMessage(cancelError),
|
||||||
originalError: paymentResult.error
|
originalError: paymentResult.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -392,7 +449,7 @@ export class SimManagementService {
|
|||||||
try {
|
try {
|
||||||
// Step 3: Only if payment successful, add data via Freebit
|
// Step 3: Only if payment successful, add data via Freebit
|
||||||
await this.freebititService.topUpSim(account, request.quotaMb, {});
|
await this.freebititService.topUpSim(account, request.quotaMb, {});
|
||||||
|
|
||||||
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -408,36 +465,39 @@ export class SimManagementService {
|
|||||||
// 1. Create a refund/credit
|
// 1. Create a refund/credit
|
||||||
// 2. Send notification to admin
|
// 2. Send notification to admin
|
||||||
// 3. Queue for retry
|
// 3. Queue for retry
|
||||||
this.logger.error(`Freebit API failed after successful payment for subscription ${subscriptionId}`, {
|
this.logger.error(
|
||||||
error: getErrorMessage(freebititError),
|
`Freebit API failed after successful payment for subscription ${subscriptionId}`,
|
||||||
userId,
|
{
|
||||||
subscriptionId,
|
error: getErrorMessage(freebititError),
|
||||||
account,
|
userId,
|
||||||
quotaMb: request.quotaMb,
|
subscriptionId,
|
||||||
invoiceId: invoice.id,
|
account,
|
||||||
transactionId: paymentResult.transactionId,
|
quotaMb: request.quotaMb,
|
||||||
paymentCaptured: true,
|
invoiceId: invoice.id,
|
||||||
});
|
transactionId: paymentResult.transactionId,
|
||||||
|
paymentCaptured: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Add a note to the invoice about the Freebit failure
|
// Add a note to the invoice about the Freebit failure
|
||||||
try {
|
try {
|
||||||
await this.whmcsService.updateInvoice({
|
await this.whmcsService.updateInvoice({
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebititError)}. Manual intervention required.`
|
notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebititError)}. Manual intervention required.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Added failure note to invoice ${invoice.id}`, {
|
this.logger.log(`Added failure note to invoice ${invoice.id}`, {
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
reason: 'Freebit API failure after payment'
|
reason: "Freebit API failure after payment",
|
||||||
});
|
});
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, {
|
this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, {
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
updateError: getErrorMessage(updateError),
|
updateError: getErrorMessage(updateError),
|
||||||
originalError: getErrorMessage(freebititError)
|
originalError: getErrorMessage(freebititError),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement refund logic here
|
// TODO: Implement refund logic here
|
||||||
// await this.whmcsService.addCredit({
|
// await this.whmcsService.addCredit({
|
||||||
// clientId: mapping.whmcsClientId,
|
// clientId: mapping.whmcsClientId,
|
||||||
@ -445,7 +505,7 @@ export class SimManagementService {
|
|||||||
// amount: costJpy,
|
// amount: costJpy,
|
||||||
// type: 'refund'
|
// type: 'refund'
|
||||||
// });
|
// });
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
|
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
|
||||||
);
|
);
|
||||||
@ -465,24 +525,24 @@ export class SimManagementService {
|
|||||||
* Get SIM top-up history
|
* Get SIM top-up history
|
||||||
*/
|
*/
|
||||||
async getSimTopUpHistory(
|
async getSimTopUpHistory(
|
||||||
userId: string,
|
userId: string,
|
||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: SimTopUpHistoryRequest
|
request: SimTopUpHistoryRequest
|
||||||
): Promise<SimTopUpHistory> {
|
): Promise<SimTopUpHistory> {
|
||||||
try {
|
try {
|
||||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
// Validate date format
|
// Validate date format
|
||||||
if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) {
|
if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) {
|
||||||
throw new BadRequestException('Dates must be in YYYYMMDD format');
|
throw new BadRequestException("Dates must be in YYYYMMDD format");
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = await this.freebititService.getSimTopUpHistory(
|
const history = await this.freebititService.getSimTopUpHistory(
|
||||||
account,
|
account,
|
||||||
request.fromDate,
|
request.fromDate,
|
||||||
request.toDate
|
request.toDate
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, {
|
this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -505,29 +565,29 @@ export class SimManagementService {
|
|||||||
* Change SIM plan
|
* Change SIM plan
|
||||||
*/
|
*/
|
||||||
async changeSimPlan(
|
async changeSimPlan(
|
||||||
userId: string,
|
userId: string,
|
||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: SimPlanChangeRequest
|
request: SimPlanChangeRequest
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
try {
|
try {
|
||||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
// Validate plan code format
|
// Validate plan code format
|
||||||
if (!request.newPlanCode || request.newPlanCode.length < 3) {
|
if (!request.newPlanCode || request.newPlanCode.length < 3) {
|
||||||
throw new BadRequestException('Invalid plan code');
|
throw new BadRequestException("Invalid plan code");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically set to 1st of next month
|
// Automatically set to 1st of next month
|
||||||
const nextMonth = new Date();
|
const nextMonth = new Date();
|
||||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||||
nextMonth.setDate(1); // Set to 1st of the month
|
nextMonth.setDate(1); // Set to 1st of the month
|
||||||
|
|
||||||
// Format as YYYYMMDD for Freebit API
|
// Format as YYYYMMDD for Freebit API
|
||||||
const year = nextMonth.getFullYear();
|
const year = nextMonth.getFullYear();
|
||||||
const month = String(nextMonth.getMonth() + 1).padStart(2, '0');
|
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(nextMonth.getDate()).padStart(2, '0');
|
const day = String(nextMonth.getDate()).padStart(2, "0");
|
||||||
const scheduledAt = `${year}${month}${day}`;
|
const scheduledAt = `${year}${month}${day}`;
|
||||||
|
|
||||||
this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, {
|
this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -539,7 +599,7 @@ export class SimManagementService {
|
|||||||
assignGlobalIp: false, // Default to no global IP
|
assignGlobalIp: false, // Default to no global IP
|
||||||
scheduledAt: scheduledAt,
|
scheduledAt: scheduledAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -573,7 +633,7 @@ export class SimManagementService {
|
|||||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
// Validate network type if provided
|
// Validate network type if provided
|
||||||
if (request.networkType && !['4G', '5G'].includes(request.networkType)) {
|
if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
|
||||||
throw new BadRequestException('networkType must be either "4G" or "5G"');
|
throw new BadRequestException('networkType must be either "4G" or "5G"');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -599,17 +659,21 @@ export class SimManagementService {
|
|||||||
/**
|
/**
|
||||||
* Cancel SIM service
|
* Cancel SIM service
|
||||||
*/
|
*/
|
||||||
async cancelSim(userId: string, subscriptionId: number, request: SimCancelRequest = {}): Promise<void> {
|
async cancelSim(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimCancelRequest = {}
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
// Validate scheduled date if provided
|
// Validate scheduled date if provided
|
||||||
if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) {
|
if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) {
|
||||||
throw new BadRequestException('Scheduled date must be in YYYYMMDD format');
|
throw new BadRequestException("Scheduled date must be in YYYYMMDD format");
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.freebititService.cancelSim(account, request.scheduledAt);
|
await this.freebititService.cancelSim(account, request.scheduledAt);
|
||||||
|
|
||||||
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -631,15 +695,15 @@ export class SimManagementService {
|
|||||||
async reissueEsimProfile(userId: string, subscriptionId: number): Promise<void> {
|
async reissueEsimProfile(userId: string, subscriptionId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
// First check if this is actually an eSIM
|
// First check if this is actually an eSIM
|
||||||
const simDetails = await this.freebititService.getSimDetails(account);
|
const simDetails = await this.freebititService.getSimDetails(account);
|
||||||
if (simDetails.simType !== 'esim') {
|
if (simDetails.simType !== "esim") {
|
||||||
throw new BadRequestException('This operation is only available for eSIM subscriptions');
|
throw new BadRequestException("This operation is only available for eSIM subscriptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.freebititService.reissueEsimProfile(account);
|
await this.freebititService.reissueEsimProfile(account);
|
||||||
|
|
||||||
this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, {
|
this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -658,7 +722,10 @@ export class SimManagementService {
|
|||||||
/**
|
/**
|
||||||
* Get comprehensive SIM information (details + usage combined)
|
* Get comprehensive SIM information (details + usage combined)
|
||||||
*/
|
*/
|
||||||
async getSimInfo(userId: string, subscriptionId: number): Promise<{
|
async getSimInfo(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<{
|
||||||
details: SimDetails;
|
details: SimDetails;
|
||||||
usage: SimUsage;
|
usage: SimUsage;
|
||||||
}> {
|
}> {
|
||||||
@ -671,9 +738,11 @@ export class SimManagementService {
|
|||||||
// If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G)
|
// If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G)
|
||||||
// by subtracting measured usage (today + recentDays) from the plan cap.
|
// by subtracting measured usage (today + recentDays) from the plan cap.
|
||||||
const normalizeNumber = (n: number) => (isFinite(n) && n > 0 ? n : 0);
|
const normalizeNumber = (n: number) => (isFinite(n) && n > 0 ? n : 0);
|
||||||
const usedMb = normalizeNumber(usage.todayUsageMb) + usage.recentDaysUsage.reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0);
|
const usedMb =
|
||||||
|
normalizeNumber(usage.todayUsageMb) +
|
||||||
|
usage.recentDaysUsage.reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0);
|
||||||
|
|
||||||
const planCapMatch = (details.planCode || '').match(/(\d+)\s*G/i);
|
const planCapMatch = (details.planCode || "").match(/(\d+)\s*G/i);
|
||||||
if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) {
|
if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) {
|
||||||
const capGb = parseInt(planCapMatch[1], 10);
|
const capGb = parseInt(planCapMatch[1], 10);
|
||||||
if (!isNaN(capGb) && capGb > 0) {
|
if (!isNaN(capGb) && capGb > 0) {
|
||||||
@ -706,25 +775,25 @@ export class SimManagementService {
|
|||||||
const errorLower = technicalError.toLowerCase();
|
const errorLower = technicalError.toLowerCase();
|
||||||
|
|
||||||
// Freebit API errors
|
// Freebit API errors
|
||||||
if (errorLower.includes('api error: ng') || errorLower.includes('account not found')) {
|
if (errorLower.includes("api error: ng") || errorLower.includes("account not found")) {
|
||||||
return "SIM account not found. Please contact support to verify your SIM configuration.";
|
return "SIM account not found. Please contact support to verify your SIM configuration.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorLower.includes('authentication failed') || errorLower.includes('auth')) {
|
if (errorLower.includes("authentication failed") || errorLower.includes("auth")) {
|
||||||
return "SIM service is temporarily unavailable. Please try again later.";
|
return "SIM service is temporarily unavailable. Please try again later.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorLower.includes('timeout') || errorLower.includes('network')) {
|
if (errorLower.includes("timeout") || errorLower.includes("network")) {
|
||||||
return "SIM service request timed out. Please try again.";
|
return "SIM service request timed out. Please try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// WHMCS errors
|
// WHMCS errors
|
||||||
if (errorLower.includes('invalid permissions') || errorLower.includes('not allowed')) {
|
if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) {
|
||||||
return "SIM service is temporarily unavailable. Please contact support for assistance.";
|
return "SIM service is temporarily unavailable. Please contact support for assistance.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic errors
|
// Generic errors
|
||||||
if (errorLower.includes('failed') || errorLower.includes('error')) {
|
if (errorLower.includes("failed") || errorLower.includes("error")) {
|
||||||
return "SIM operation failed. Please try again or contact support.";
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,14 +6,14 @@ import { Logger } from "nestjs-pino";
|
|||||||
export class SimUsageStoreService {
|
export class SimUsageStoreService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private normalizeDate(date?: Date): Date {
|
private normalizeDate(date?: Date): Date {
|
||||||
const d = date ? new Date(date) : new Date();
|
const d = date ? new Date(date) : new Date();
|
||||||
// strip time to YYYY-MM-DD
|
// strip time to YYYY-MM-DD
|
||||||
const iso = d.toISOString().split('T')[0];
|
const iso = d.toISOString().split("T")[0];
|
||||||
return new Date(iso + 'T00:00:00.000Z');
|
return new Date(iso + "T00:00:00.000Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertToday(account: string, usageMb: number, date?: Date): Promise<void> {
|
async upsertToday(account: string, usageMb: number, date?: Date): Promise<void> {
|
||||||
@ -29,21 +29,26 @@ export class SimUsageStoreService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLastNDays(account: string, days = 30): Promise<Array<{ date: string; usageMb: number }>> {
|
async getLastNDays(
|
||||||
|
account: string,
|
||||||
|
days = 30
|
||||||
|
): Promise<Array<{ date: string; usageMb: number }>> {
|
||||||
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 as any).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 }>;
|
||||||
return rows.map((r) => ({ date: r.date.toISOString().split('T')[0], usageMb: r.usageMb }));
|
return rows.map(r => ({ date: r.date.toISOString().split("T")[0], usageMb: r.usageMb }));
|
||||||
}
|
}
|
||||||
|
|
||||||
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({ where: { date: { lt: firstOfMonth } } });
|
const result = await (this.prisma as any).simUsageDaily.deleteMany({
|
||||||
|
where: { date: { lt: firstOfMonth } },
|
||||||
|
});
|
||||||
return result.count;
|
return result.count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import type { RequestWithUser } from "../auth/auth.types";
|
|||||||
export class SubscriptionsController {
|
export class SubscriptionsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsService: SubscriptionsService,
|
||||||
private readonly simManagementService: SimManagementService,
|
private readonly simManagementService: SimManagementService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ -270,7 +270,7 @@ export class SubscriptionsController {
|
|||||||
if (!fromDate || !toDate) {
|
if (!fromDate || !toDate) {
|
||||||
throw new BadRequestException("fromDate and toDate are required");
|
throw new BadRequestException("fromDate and toDate are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, {
|
return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, {
|
||||||
fromDate,
|
fromDate,
|
||||||
toDate,
|
toDate,
|
||||||
@ -297,7 +297,8 @@ export class SubscriptionsController {
|
|||||||
async topUpSim(
|
async topUpSim(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: {
|
@Body()
|
||||||
|
body: {
|
||||||
quotaMb: number;
|
quotaMb: number;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@ -308,7 +309,8 @@ export class SubscriptionsController {
|
|||||||
@Post(":id/sim/change-plan")
|
@Post(":id/sim/change-plan")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Change SIM plan",
|
summary: "Change SIM plan",
|
||||||
description: "Change the SIM service plan. The change will be automatically scheduled for the 1st of the next month.",
|
description:
|
||||||
|
"Change the SIM service plan. The change will be automatically scheduled for the 1st of the next month.",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
@ -325,15 +327,16 @@ export class SubscriptionsController {
|
|||||||
async changeSimPlan(
|
async changeSimPlan(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: {
|
@Body()
|
||||||
|
body: {
|
||||||
newPlanCode: string;
|
newPlanCode: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
|
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "SIM plan change completed successfully",
|
message: "SIM plan change completed successfully",
|
||||||
...result
|
...result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,7 +351,11 @@ export class SubscriptionsController {
|
|||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
scheduledAt: { type: "string", description: "Schedule cancellation (YYYYMMDD)", example: "20241231" },
|
scheduledAt: {
|
||||||
|
type: "string",
|
||||||
|
description: "Schedule cancellation (YYYYMMDD)",
|
||||||
|
example: "20241231",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: false,
|
required: false,
|
||||||
@ -382,7 +389,8 @@ export class SubscriptionsController {
|
|||||||
@Post(":id/sim/features")
|
@Post(":id/sim/features")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Update SIM features",
|
summary: "Update SIM features",
|
||||||
description: "Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)",
|
description:
|
||||||
|
"Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)",
|
||||||
})
|
})
|
||||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
@ApiBody({
|
@ApiBody({
|
||||||
@ -406,7 +414,7 @@ export class SubscriptionsController {
|
|||||||
voiceMailEnabled?: boolean;
|
voiceMailEnabled?: boolean;
|
||||||
callWaitingEnabled?: boolean;
|
callWaitingEnabled?: boolean;
|
||||||
internationalRoamingEnabled?: boolean;
|
internationalRoamingEnabled?: boolean;
|
||||||
networkType?: '4G' | '5G';
|
networkType?: "4G" | "5G";
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { FreebititService } from './freebit.service';
|
import { FreebititService } from "./freebit.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [FreebititService],
|
providers: [FreebititService],
|
||||||
|
|||||||
418
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
418
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
@ -1,6 +1,11 @@
|
|||||||
import { Injectable, Inject, BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
import {
|
||||||
import { ConfigService } from '@nestjs/config';
|
Injectable,
|
||||||
import { Logger } from 'nestjs-pino';
|
Inject,
|
||||||
|
BadRequestException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
import {
|
import {
|
||||||
FreebititConfig,
|
FreebititConfig,
|
||||||
FreebititAuthRequest,
|
FreebititAuthRequest,
|
||||||
@ -26,8 +31,8 @@ import {
|
|||||||
SimTopUpHistory,
|
SimTopUpHistory,
|
||||||
FreebititError,
|
FreebititError,
|
||||||
FreebititAddSpecRequest,
|
FreebititAddSpecRequest,
|
||||||
FreebititAddSpecResponse
|
FreebititAddSpecResponse,
|
||||||
} from './interfaces/freebit.types';
|
} from "./interfaces/freebit.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreebititService {
|
export class FreebititService {
|
||||||
@ -39,23 +44,25 @@ export class FreebititService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.config = {
|
this.config = {
|
||||||
baseUrl: this.configService.get<string>('FREEBIT_BASE_URL') || 'https://i1-q.mvno.net/emptool/api/',
|
baseUrl:
|
||||||
oemId: this.configService.get<string>('FREEBIT_OEM_ID') || 'PASI',
|
this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1-q.mvno.net/emptool/api/",
|
||||||
oemKey: this.configService.get<string>('FREEBIT_OEM_KEY') || '',
|
oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI",
|
||||||
timeout: this.configService.get<number>('FREEBIT_TIMEOUT') || 30000,
|
oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "",
|
||||||
retryAttempts: this.configService.get<number>('FREEBIT_RETRY_ATTEMPTS') || 3,
|
timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000,
|
||||||
detailsEndpoint: this.configService.get<string>('FREEBIT_DETAILS_ENDPOINT') || '/master/getAcnt/',
|
retryAttempts: this.configService.get<number>("FREEBIT_RETRY_ATTEMPTS") || 3,
|
||||||
|
detailsEndpoint:
|
||||||
|
this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Warn if critical configuration is missing
|
// Warn if critical configuration is missing
|
||||||
if (!this.config.oemKey) {
|
if (!this.config.oemKey) {
|
||||||
this.logger.warn('FREEBIT_OEM_KEY is not configured. SIM management features will not work.');
|
this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work.");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('Freebit service initialized', {
|
this.logger.debug("Freebit service initialized", {
|
||||||
baseUrl: this.config.baseUrl,
|
baseUrl: this.config.baseUrl,
|
||||||
oemId: this.config.oemId,
|
oemId: this.config.oemId,
|
||||||
hasOemKey: !!this.config.oemKey,
|
hasOemKey: !!this.config.oemKey,
|
||||||
@ -65,19 +72,19 @@ export class FreebititService {
|
|||||||
/**
|
/**
|
||||||
* Map Freebit SIM status to portal status
|
* Map Freebit SIM status to portal status
|
||||||
*/
|
*/
|
||||||
private mapSimStatus(freebititStatus: string): 'active' | 'suspended' | 'cancelled' | 'pending' {
|
private mapSimStatus(freebititStatus: string): "active" | "suspended" | "cancelled" | "pending" {
|
||||||
switch (freebititStatus) {
|
switch (freebititStatus) {
|
||||||
case 'active':
|
case "active":
|
||||||
return 'active';
|
return "active";
|
||||||
case 'suspended':
|
case "suspended":
|
||||||
return 'suspended';
|
return "suspended";
|
||||||
case 'temporary':
|
case "temporary":
|
||||||
case 'waiting':
|
case "waiting":
|
||||||
return 'pending';
|
return "pending";
|
||||||
case 'obsolete':
|
case "obsolete":
|
||||||
return 'cancelled';
|
return "cancelled";
|
||||||
default:
|
default:
|
||||||
return 'pending';
|
return "pending";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +100,7 @@ export class FreebititService {
|
|||||||
try {
|
try {
|
||||||
// Check if configuration is available
|
// Check if configuration is available
|
||||||
if (!this.config.oemKey) {
|
if (!this.config.oemKey) {
|
||||||
throw new Error('Freebit API not configured: FREEBIT_OEM_KEY is missing');
|
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: FreebititAuthRequest = {
|
const request: FreebititAuthRequest = {
|
||||||
@ -102,9 +109,9 @@ export class FreebititService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
|
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
},
|
||||||
body: `json=${JSON.stringify(request)}`,
|
body: `json=${JSON.stringify(request)}`,
|
||||||
});
|
});
|
||||||
@ -113,9 +120,9 @@ export class FreebititService {
|
|||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as FreebititAuthResponse;
|
const data = (await response.json()) as FreebititAuthResponse;
|
||||||
|
|
||||||
if (data.resultCode !== '100') {
|
if (data.resultCode !== "100") {
|
||||||
throw new FreebititErrorImpl(
|
throw new FreebititErrorImpl(
|
||||||
`Authentication failed: ${data.status.message}`,
|
`Authentication failed: ${data.status.message}`,
|
||||||
data.resultCode,
|
data.resultCode,
|
||||||
@ -130,30 +137,27 @@ export class FreebititService {
|
|||||||
expiresAt: Date.now() + 50 * 60 * 1000,
|
expiresAt: Date.now() + 50 * 60 * 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log('Successfully authenticated with Freebit API');
|
this.logger.log("Successfully authenticated with Freebit API");
|
||||||
return data.authKey;
|
return data.authKey;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error('Failed to authenticate with Freebit API', { error: error.message });
|
this.logger.error("Failed to authenticate with Freebit API", { error: error.message });
|
||||||
throw new InternalServerErrorException('Failed to authenticate with Freebit API');
|
throw new InternalServerErrorException("Failed to authenticate with Freebit API");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make authenticated API request with error handling
|
* Make authenticated API request with error handling
|
||||||
*/
|
*/
|
||||||
private async makeAuthenticatedRequest<T>(
|
private async makeAuthenticatedRequest<T>(endpoint: string, data: any): Promise<T> {
|
||||||
endpoint: string,
|
|
||||||
data: any
|
|
||||||
): Promise<T> {
|
|
||||||
const authKey = await this.getAuthKey();
|
const authKey = await this.getAuthKey();
|
||||||
const requestData = { ...data, authKey };
|
const requestData = { ...data, authKey };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${this.config.baseUrl}${endpoint}`;
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
},
|
||||||
body: `json=${JSON.stringify(requestData)}`,
|
body: `json=${JSON.stringify(requestData)}`,
|
||||||
});
|
});
|
||||||
@ -164,7 +168,7 @@ export class FreebititService {
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
bodySnippet = text ? text.slice(0, 500) : undefined;
|
bodySnippet = text ? text.slice(0, 500) : undefined;
|
||||||
} catch {}
|
} catch {}
|
||||||
this.logger.error('Freebit API non-OK response', {
|
this.logger.error("Freebit API non-OK response", {
|
||||||
endpoint,
|
endpoint,
|
||||||
url,
|
url,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@ -174,31 +178,31 @@ export class FreebititService {
|
|||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
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') {
|
if (responseData && (responseData as any).resultCode !== "100") {
|
||||||
const errorData = responseData as any;
|
const errorData = responseData as any;
|
||||||
const errorMessage = errorData.status?.message || 'Unknown error';
|
const errorMessage = errorData.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.includes("auth") || errorMessage.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.includes("timeout") || errorMessage.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: errorData.resultCode,
|
||||||
statusCode: errorData.status?.statusCode,
|
statusCode: errorData.status?.statusCode,
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
userFriendlyMessage
|
userFriendlyMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new FreebititErrorImpl(
|
throw new FreebititErrorImpl(
|
||||||
userFriendlyMessage,
|
userFriendlyMessage,
|
||||||
errorData.resultCode,
|
errorData.resultCode,
|
||||||
@ -207,7 +211,7 @@ export class FreebititService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('Freebit API Request Success', {
|
this.logger.debug("Freebit API Request Success", {
|
||||||
endpoint,
|
endpoint,
|
||||||
resultCode: (responseData as any).resultCode,
|
resultCode: (responseData as any).resultCode,
|
||||||
});
|
});
|
||||||
@ -217,9 +221,13 @@ export class FreebititService {
|
|||||||
if (error instanceof FreebititErrorImpl) {
|
if (error instanceof FreebititErrorImpl) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: (error as any).message });
|
this.logger.error(`Freebit API request failed: ${endpoint}`, {
|
||||||
throw new InternalServerErrorException(`Freebit API request failed: ${(error as any).message}`);
|
error: (error as any).message,
|
||||||
|
});
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Freebit API request failed: ${(error as any).message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,30 +236,32 @@ export class FreebititService {
|
|||||||
*/
|
*/
|
||||||
async getSimDetails(account: string): Promise<SimDetails> {
|
async getSimDetails(account: string): Promise<SimDetails> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititAccountDetailsRequest, 'authKey'> = {
|
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
|
||||||
version: '2',
|
version: "2",
|
||||||
requestDatas: [{ kind: 'MVNO', account }],
|
requestDatas: [{ kind: "MVNO", account }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const configured = this.config.detailsEndpoint || '/master/getAcnt/';
|
const configured = this.config.detailsEndpoint || "/master/getAcnt/";
|
||||||
const candidates = Array.from(new Set([
|
const candidates = Array.from(
|
||||||
configured,
|
new Set([
|
||||||
configured.replace(/\/$/, ''),
|
configured,
|
||||||
'/master/getAcnt/',
|
configured.replace(/\/$/, ""),
|
||||||
'/master/getAcnt',
|
"/master/getAcnt/",
|
||||||
'/mvno/getAccountDetail/',
|
"/master/getAcnt",
|
||||||
'/mvno/getAccountDetail',
|
"/mvno/getAccountDetail/",
|
||||||
'/mvno/getAcntDetail/',
|
"/mvno/getAccountDetail",
|
||||||
'/mvno/getAcntDetail',
|
"/mvno/getAcntDetail/",
|
||||||
'/mvno/getAccountInfo/',
|
"/mvno/getAcntDetail",
|
||||||
'/mvno/getAccountInfo',
|
"/mvno/getAccountInfo/",
|
||||||
'/mvno/getSubscriberInfo/',
|
"/mvno/getAccountInfo",
|
||||||
'/mvno/getSubscriberInfo',
|
"/mvno/getSubscriberInfo/",
|
||||||
'/mvno/getInfo/',
|
"/mvno/getSubscriberInfo",
|
||||||
'/mvno/getInfo',
|
"/mvno/getInfo/",
|
||||||
'/master/getDetail/',
|
"/mvno/getInfo",
|
||||||
'/master/getDetail',
|
"/master/getDetail/",
|
||||||
]));
|
"/master/getDetail",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
let response: FreebititAccountDetailsResponse | undefined;
|
let response: FreebititAccountDetailsResponse | undefined;
|
||||||
let lastError: any;
|
let lastError: any;
|
||||||
@ -260,11 +270,14 @@ export class FreebititService {
|
|||||||
if (ep !== candidates[0]) {
|
if (ep !== candidates[0]) {
|
||||||
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
||||||
}
|
}
|
||||||
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(ep, request);
|
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(
|
||||||
|
ep,
|
||||||
|
request
|
||||||
|
);
|
||||||
break; // success
|
break; // success
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
if (typeof err?.message === 'string' && err.message.includes('HTTP 404')) {
|
if (typeof err?.message === "string" && err.message.includes("HTTP 404")) {
|
||||||
// try next candidate
|
// try next candidate
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -274,22 +287,27 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
throw lastError || new InternalServerErrorException('Failed to fetch SIM details: all endpoints failed');
|
throw (
|
||||||
|
lastError ||
|
||||||
|
new InternalServerErrorException("Failed to fetch SIM details: all endpoints failed")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const datas = (response as any).responseDatas;
|
const datas = (response as any).responseDatas;
|
||||||
const list = Array.isArray(datas) ? datas : (datas ? [datas] : []);
|
const list = Array.isArray(datas) ? datas : datas ? [datas] : [];
|
||||||
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 = list.find((d: any) => (d.kind || '').toString().toUpperCase() === 'MVNO') || list[0];
|
const mvno =
|
||||||
const simData = mvno as any;
|
list.find((d: any) => (d.kind || "").toString().toUpperCase() === "MVNO") || list[0];
|
||||||
|
const simData = mvno;
|
||||||
|
|
||||||
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
|
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
|
||||||
const startDate = startDateRaw && /^\d{8}$/.test(startDateRaw)
|
const startDate =
|
||||||
? `${startDateRaw.slice(0,4)}-${startDateRaw.slice(4,6)}-${startDateRaw.slice(6,8)}`
|
startDateRaw && /^\d{8}$/.test(startDateRaw)
|
||||||
: startDateRaw;
|
? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}`
|
||||||
|
: startDateRaw;
|
||||||
|
|
||||||
const simDetails: SimDetails = {
|
const simDetails: SimDetails = {
|
||||||
account: String(simData.account ?? account),
|
account: String(simData.account ?? account),
|
||||||
@ -298,13 +316,14 @@ export class FreebititService {
|
|||||||
imsi: simData.imsi ? String(simData.imsi) : undefined,
|
imsi: simData.imsi ? String(simData.imsi) : undefined,
|
||||||
eid: simData.eid,
|
eid: simData.eid,
|
||||||
planCode: simData.planCode,
|
planCode: simData.planCode,
|
||||||
status: this.mapSimStatus(String(simData.state || 'pending')),
|
status: this.mapSimStatus(String(simData.state || "pending")),
|
||||||
simType: simData.eid ? 'esim' : 'physical',
|
simType: simData.eid ? "esim" : "physical",
|
||||||
size: simData.size,
|
size: simData.size,
|
||||||
hasVoice: simData.talk === 10,
|
hasVoice: simData.talk === 10,
|
||||||
hasSms: simData.sms === 10,
|
hasSms: simData.sms === 10,
|
||||||
remainingQuotaKb: typeof simData.quota === 'number' ? simData.quota : 0,
|
remainingQuotaKb: typeof simData.quota === "number" ? simData.quota : 0,
|
||||||
remainingQuotaMb: typeof simData.quota === 'number' ? Math.round((simData.quota / 1000) * 100) / 100 : 0,
|
remainingQuotaMb:
|
||||||
|
typeof simData.quota === "number" ? Math.round((simData.quota / 1000) * 100) / 100 : 0,
|
||||||
startDate,
|
startDate,
|
||||||
ipv4: simData.ipv4,
|
ipv4: simData.ipv4,
|
||||||
ipv6: simData.ipv6,
|
ipv6: simData.ipv6,
|
||||||
@ -312,10 +331,14 @@ export class FreebititService {
|
|||||||
callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10,
|
callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10,
|
||||||
internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10,
|
internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10,
|
||||||
networkType: simData.contractLine || undefined,
|
networkType: simData.contractLine || undefined,
|
||||||
pendingOperations: simData.async ? [{
|
pendingOperations: simData.async
|
||||||
operation: simData.async.func,
|
? [
|
||||||
scheduledDate: String(simData.async.date),
|
{
|
||||||
}] : undefined,
|
operation: simData.async.func,
|
||||||
|
scheduledDate: String(simData.async.date),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Retrieved SIM details for account ${account}`, {
|
this.logger.log(`Retrieved SIM details for account ${account}`, {
|
||||||
@ -326,7 +349,9 @@ export class FreebititService {
|
|||||||
|
|
||||||
return simDetails;
|
return simDetails;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to get SIM details for account ${account}`, { error: error.message });
|
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -336,26 +361,26 @@ export class FreebititService {
|
|||||||
*/
|
*/
|
||||||
async getSimUsage(account: string): Promise<SimUsage> {
|
async getSimUsage(account: string): Promise<SimUsage> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititTrafficInfoRequest, 'authKey'> = { account };
|
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
|
||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
|
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
|
||||||
'/mvno/getTrafficInfo/',
|
"/mvno/getTrafficInfo/",
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
||||||
const recentDaysData = response.traffic.inRecentDays.split(',').map((usage, index) => ({
|
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
||||||
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
||||||
usageKb: parseInt(usage, 10) || 0,
|
usageKb: parseInt(usage, 10) || 0,
|
||||||
usageMb: Math.round(parseInt(usage, 10) / 1000 * 100) / 100,
|
usageMb: Math.round((parseInt(usage, 10) / 1000) * 100) / 100,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const simUsage: SimUsage = {
|
const simUsage: SimUsage = {
|
||||||
account,
|
account,
|
||||||
todayUsageKb,
|
todayUsageKb,
|
||||||
todayUsageMb: Math.round(todayUsageKb / 1000 * 100) / 100,
|
todayUsageMb: Math.round((todayUsageKb / 1000) * 100) / 100,
|
||||||
recentDaysUsage: recentDaysData,
|
recentDaysUsage: recentDaysData,
|
||||||
isBlacklisted: response.traffic.blackList === '10',
|
isBlacklisted: response.traffic.blackList === "10",
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Retrieved SIM usage for account ${account}`, {
|
this.logger.log(`Retrieved SIM usage for account ${account}`, {
|
||||||
@ -374,11 +399,15 @@ export class FreebititService {
|
|||||||
/**
|
/**
|
||||||
* Top up SIM data quota
|
* Top up SIM data quota
|
||||||
*/
|
*/
|
||||||
async topUpSim(account: string, quotaMb: number, options: {
|
async topUpSim(
|
||||||
campaignCode?: string;
|
account: string,
|
||||||
expiryDate?: string;
|
quotaMb: number,
|
||||||
scheduledAt?: string;
|
options: {
|
||||||
} = {}): Promise<void> {
|
campaignCode?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
scheduledAt?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Units per endpoint:
|
// Units per endpoint:
|
||||||
// - Immediate (PA04-04 /master/addSpec/): quota in MB (string), requires kind: 'MVNO'
|
// - Immediate (PA04-04 /master/addSpec/): quota in MB (string), requires kind: 'MVNO'
|
||||||
@ -388,9 +417,9 @@ export class FreebititService {
|
|||||||
const quotaKbStr = String(Math.round(quotaKb));
|
const quotaKbStr = String(Math.round(quotaKb));
|
||||||
|
|
||||||
const isScheduled = !!options.scheduledAt;
|
const isScheduled = !!options.scheduledAt;
|
||||||
const endpoint = isScheduled ? '/mvno/eachQuota/' : '/master/addSpec/';
|
const endpoint = isScheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
||||||
|
|
||||||
let request: Omit<FreebititTopUpRequest, 'authKey'>;
|
let request: Omit<FreebititTopUpRequest, "authKey">;
|
||||||
if (isScheduled) {
|
if (isScheduled) {
|
||||||
// PA05-22: KB + runTime
|
// PA05-22: KB + runTime
|
||||||
request = {
|
request = {
|
||||||
@ -404,7 +433,7 @@ export class FreebititService {
|
|||||||
// PA04-04: MB + kind
|
// PA04-04: MB + kind
|
||||||
request = {
|
request = {
|
||||||
account,
|
account,
|
||||||
kind: 'MVNO',
|
kind: "MVNO",
|
||||||
quota: quotaMbStr,
|
quota: quotaMbStr,
|
||||||
quotaCode: options.campaignCode,
|
quotaCode: options.campaignCode,
|
||||||
expire: options.expiryDate,
|
expire: options.expiryDate,
|
||||||
@ -418,12 +447,12 @@ export class FreebititService {
|
|||||||
endpoint,
|
endpoint,
|
||||||
quotaMb,
|
quotaMb,
|
||||||
quotaKb,
|
quotaKb,
|
||||||
units: isScheduled ? 'KB (PA05-22)' : 'MB (PA04-04)',
|
units: isScheduled ? "KB (PA05-22)" : "MB (PA04-04)",
|
||||||
campaignCode: options.campaignCode,
|
campaignCode: options.campaignCode,
|
||||||
scheduled: isScheduled,
|
scheduled: isScheduled,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to top up SIM ${account}`, {
|
this.logger.error(`Failed to top up SIM ${account}`, {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
account,
|
account,
|
||||||
quotaMb,
|
quotaMb,
|
||||||
@ -435,16 +464,20 @@ export class FreebititService {
|
|||||||
/**
|
/**
|
||||||
* Get SIM top-up history
|
* Get SIM top-up history
|
||||||
*/
|
*/
|
||||||
async getSimTopUpHistory(account: string, fromDate: string, toDate: string): Promise<SimTopUpHistory> {
|
async getSimTopUpHistory(
|
||||||
|
account: string,
|
||||||
|
fromDate: string,
|
||||||
|
toDate: string
|
||||||
|
): Promise<SimTopUpHistory> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititQuotaHistoryRequest, 'authKey'> = {
|
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
fromDate,
|
fromDate,
|
||||||
toDate,
|
toDate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
|
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
|
||||||
'/mvno/getQuotaHistory/',
|
"/mvno/getQuotaHistory/",
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -454,7 +487,7 @@ export class FreebititService {
|
|||||||
additionCount: response.count,
|
additionCount: response.count,
|
||||||
history: response.quotaHistory.map(item => ({
|
history: response.quotaHistory.map(item => ({
|
||||||
quotaKb: parseInt(item.quota, 10),
|
quotaKb: parseInt(item.quota, 10),
|
||||||
quotaMb: Math.round(parseInt(item.quota, 10) / 1000 * 100) / 100,
|
quotaMb: Math.round((parseInt(item.quota, 10) / 1000) * 100) / 100,
|
||||||
addedDate: item.date,
|
addedDate: item.date,
|
||||||
expiryDate: item.expire,
|
expiryDate: item.expire,
|
||||||
campaignCode: item.quotaCode,
|
campaignCode: item.quotaCode,
|
||||||
@ -469,7 +502,9 @@ export class FreebititService {
|
|||||||
|
|
||||||
return history;
|
return history;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to get SIM top-up history for account ${account}`, { error: error.message });
|
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -477,20 +512,24 @@ export class FreebititService {
|
|||||||
/**
|
/**
|
||||||
* Change SIM plan
|
* Change SIM plan
|
||||||
*/
|
*/
|
||||||
async changeSimPlan(account: string, newPlanCode: string, options: {
|
async changeSimPlan(
|
||||||
assignGlobalIp?: boolean;
|
account: string,
|
||||||
scheduledAt?: string;
|
newPlanCode: string,
|
||||||
} = {}): Promise<{ ipv4?: string; ipv6?: string }> {
|
options: {
|
||||||
|
assignGlobalIp?: boolean;
|
||||||
|
scheduledAt?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititPlanChangeRequest, 'authKey'> = {
|
const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
planCode: newPlanCode,
|
planCode: newPlanCode,
|
||||||
globalip: options.assignGlobalIp ? '1' : '0',
|
globalip: options.assignGlobalIp ? "1" : "0",
|
||||||
runTime: options.scheduledAt,
|
runTime: options.scheduledAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
|
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
|
||||||
'/mvno/changePlan/',
|
"/mvno/changePlan/",
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -506,7 +545,7 @@ export class FreebititService {
|
|||||||
ipv6: response.ipv6,
|
ipv6: response.ipv6,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to change SIM plan for account ${account}`, {
|
this.logger.error(`Failed to change SIM plan for account ${account}`, {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
account,
|
account,
|
||||||
newPlanCode,
|
newPlanCode,
|
||||||
@ -519,35 +558,40 @@ export class FreebititService {
|
|||||||
* Update SIM optional features (voicemail, call waiting, international roaming, network type)
|
* Update SIM optional features (voicemail, call waiting, international roaming, network type)
|
||||||
* Uses AddSpec endpoint for immediate changes
|
* Uses AddSpec endpoint for immediate changes
|
||||||
*/
|
*/
|
||||||
async updateSimFeatures(account: string, features: {
|
async updateSimFeatures(
|
||||||
voiceMailEnabled?: boolean;
|
account: string,
|
||||||
callWaitingEnabled?: boolean;
|
features: {
|
||||||
internationalRoamingEnabled?: boolean;
|
voiceMailEnabled?: boolean;
|
||||||
networkType?: string; // '4G' | '5G'
|
callWaitingEnabled?: boolean;
|
||||||
}): Promise<void> {
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: string; // '4G' | '5G'
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititAddSpecRequest, 'authKey'> = {
|
const request: Omit<FreebititAddSpecRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
kind: 'MVNO',
|
kind: "MVNO",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof features.voiceMailEnabled === 'boolean') {
|
if (typeof features.voiceMailEnabled === "boolean") {
|
||||||
request.voiceMail = features.voiceMailEnabled ? '10' as const : '20' as const;
|
request.voiceMail = features.voiceMailEnabled ? ("10" as const) : ("20" as const);
|
||||||
request.voicemail = request.voiceMail; // include alternate casing for compatibility
|
request.voicemail = request.voiceMail; // include alternate casing for compatibility
|
||||||
}
|
}
|
||||||
if (typeof features.callWaitingEnabled === 'boolean') {
|
if (typeof features.callWaitingEnabled === "boolean") {
|
||||||
request.callWaiting = features.callWaitingEnabled ? '10' as const : '20' as const;
|
request.callWaiting = features.callWaitingEnabled ? ("10" as const) : ("20" as const);
|
||||||
request.callwaiting = request.callWaiting;
|
request.callwaiting = request.callWaiting;
|
||||||
}
|
}
|
||||||
if (typeof features.internationalRoamingEnabled === 'boolean') {
|
if (typeof features.internationalRoamingEnabled === "boolean") {
|
||||||
request.worldWing = features.internationalRoamingEnabled ? '10' as const : '20' as const;
|
request.worldWing = features.internationalRoamingEnabled
|
||||||
|
? ("10" as const)
|
||||||
|
: ("20" as const);
|
||||||
request.worldwing = request.worldWing;
|
request.worldwing = request.worldWing;
|
||||||
}
|
}
|
||||||
if (features.networkType) {
|
if (features.networkType) {
|
||||||
request.contractLine = features.networkType;
|
request.contractLine = features.networkType;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>('/master/addSpec/', request);
|
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>("/master/addSpec/", request);
|
||||||
|
|
||||||
this.logger.log(`Updated SIM features for account ${account}`, {
|
this.logger.log(`Updated SIM features for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
@ -570,13 +614,13 @@ export class FreebititService {
|
|||||||
*/
|
*/
|
||||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititCancelPlanRequest, 'authKey'> = {
|
const request: Omit<FreebititCancelPlanRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
runTime: scheduledAt,
|
runTime: scheduledAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
|
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
|
||||||
'/mvno/releasePlan/',
|
"/mvno/releasePlan/",
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -585,7 +629,7 @@ export class FreebititService {
|
|||||||
scheduled: !!scheduledAt,
|
scheduled: !!scheduledAt,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
account,
|
account,
|
||||||
});
|
});
|
||||||
@ -603,62 +647,71 @@ export class FreebititService {
|
|||||||
|
|
||||||
// Fetch details to get current EID and plan/network where available
|
// Fetch details to get current EID and plan/network where available
|
||||||
const details = await this.getSimDetails(account);
|
const details = await this.getSimDetails(account);
|
||||||
if (details.simType !== 'esim') {
|
if (details.simType !== "esim") {
|
||||||
throw new BadRequestException('This operation is only available for eSIM subscriptions');
|
throw new BadRequestException("This operation is only available for eSIM subscriptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!details.eid) {
|
if (!details.eid) {
|
||||||
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: import("./interfaces/freebit.types").FreebititEsimAccountActivationRequest = {
|
||||||
authKey,
|
authKey,
|
||||||
aladinOperated: '20',
|
aladinOperated: "20",
|
||||||
createType: 'reissue',
|
createType: "reissue",
|
||||||
eid: details.eid, // existing EID used for reissue
|
eid: details.eid, // existing EID used for reissue
|
||||||
account,
|
account,
|
||||||
simkind: 'esim',
|
simkind: "esim",
|
||||||
addKind: 'R',
|
addKind: "R",
|
||||||
// Optional enrichments omitted to minimize validation mismatches
|
// Optional enrichments omitted to minimize validation mismatches
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = `${this.config.baseUrl}/mvno/esim/addAcct/`;
|
const url = `${this.config.baseUrl}/mvno/esim/addAcct/`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => "");
|
||||||
this.logger.error('Freebit PA05-41 HTTP error', { url, status: response.status, statusText: response.statusText, body: text?.slice(0, 500) });
|
this.logger.error("Freebit PA05-41 HTTP error", {
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body: text?.slice(0, 500),
|
||||||
|
});
|
||||||
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
|
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as import('./interfaces/freebit.types').FreebititEsimAccountActivationResponse;
|
const data =
|
||||||
const rc = typeof data.resultCode === 'number' ? String(data.resultCode) : (data.resultCode || '');
|
(await response.json()) as import("./interfaces/freebit.types").FreebititEsimAccountActivationResponse;
|
||||||
if (rc !== '100') {
|
const rc =
|
||||||
const message = data.message || 'Unknown error';
|
typeof data.resultCode === "number" ? String(data.resultCode) : data.resultCode || "";
|
||||||
this.logger.error('Freebit PA05-41 API error response', {
|
if (rc !== "100") {
|
||||||
endpoint: '/mvno/esim/addAcct/',
|
const message = data.message || "Unknown error";
|
||||||
|
this.logger.error("Freebit PA05-41 API error response", {
|
||||||
|
endpoint: "/mvno/esim/addAcct/",
|
||||||
resultCode: data.resultCode,
|
resultCode: data.resultCode,
|
||||||
statusCode: data.statusCode,
|
statusCode: data.statusCode,
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
throw new FreebititErrorImpl(
|
throw new FreebititErrorImpl(
|
||||||
`API Error: ${message}`,
|
`API Error: ${message}`,
|
||||||
rc || '0',
|
rc || "0",
|
||||||
data.statusCode || '0',
|
data.statusCode || "0",
|
||||||
message
|
message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Successfully reissued eSIM profile via PA05-41 for account ${account}`, { account });
|
this.logger.log(`Successfully reissued eSIM profile via PA05-41 for account ${account}`, {
|
||||||
|
account,
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof BadRequestException) throw error;
|
if (error instanceof BadRequestException) throw error;
|
||||||
this.logger.error(`Failed to reissue eSIM profile via PA05-41 for account ${account}`, {
|
this.logger.error(`Failed to reissue eSIM profile via PA05-41 for account ${account}`, {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
account,
|
account,
|
||||||
});
|
});
|
||||||
@ -670,7 +723,7 @@ export class FreebititService {
|
|||||||
* Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation)
|
* Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation)
|
||||||
*/
|
*/
|
||||||
async reissueEsimProfileEnhanced(
|
async reissueEsimProfileEnhanced(
|
||||||
account: string,
|
account: string,
|
||||||
newEid: string,
|
newEid: string,
|
||||||
options: {
|
options: {
|
||||||
oldProductNumber?: string;
|
oldProductNumber?: string;
|
||||||
@ -679,11 +732,11 @@ export class FreebititService {
|
|||||||
} = {}
|
} = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititEsimAddAccountRequest, 'authKey'> = {
|
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
|
||||||
aladinOperated: '20',
|
aladinOperated: "20",
|
||||||
account,
|
account,
|
||||||
eid: newEid,
|
eid: newEid,
|
||||||
addKind: 'R', // R = reissue
|
addKind: "R", // R = reissue
|
||||||
reissue: {
|
reissue: {
|
||||||
oldProductNumber: options.oldProductNumber,
|
oldProductNumber: options.oldProductNumber,
|
||||||
oldEid: options.oldEid,
|
oldEid: options.oldEid,
|
||||||
@ -696,18 +749,18 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
|
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
|
||||||
'/mvno/esim/addAcnt/',
|
"/mvno/esim/addAcnt/",
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
newEid,
|
newEid,
|
||||||
oldProductNumber: options.oldProductNumber,
|
oldProductNumber: options.oldProductNumber,
|
||||||
oldEid: options.oldEid,
|
oldEid: options.oldEid,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
account,
|
account,
|
||||||
newEid,
|
newEid,
|
||||||
@ -724,7 +777,7 @@ export class FreebititService {
|
|||||||
await this.getAuthKey();
|
await this.getAuthKey();
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error('Freebit API health check failed', { error: error.message });
|
this.logger.error("Freebit API health check failed", { error: error.message });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -736,14 +789,9 @@ class FreebititErrorImpl extends Error {
|
|||||||
public readonly statusCode: string;
|
public readonly statusCode: string;
|
||||||
public readonly freebititMessage: string;
|
public readonly freebititMessage: string;
|
||||||
|
|
||||||
constructor(
|
constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) {
|
||||||
message: string,
|
|
||||||
resultCode: string,
|
|
||||||
statusCode: string,
|
|
||||||
freebititMessage: string
|
|
||||||
) {
|
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'FreebititError';
|
this.name = "FreebititError";
|
||||||
this.resultCode = resultCode;
|
this.resultCode = resultCode;
|
||||||
this.statusCode = statusCode;
|
this.statusCode = statusCode;
|
||||||
this.freebititMessage = freebititMessage;
|
this.freebititMessage = freebititMessage;
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
// Freebit API Type Definitions
|
// Freebit API Type Definitions
|
||||||
|
|
||||||
export interface FreebititAuthRequest {
|
export interface FreebititAuthRequest {
|
||||||
oemId: string; // 4-char alphanumeric ISP identifier
|
oemId: string; // 4-char alphanumeric ISP identifier
|
||||||
oemKey: string; // 32-char auth key
|
oemKey: string; // 32-char auth key
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititAuthResponse {
|
export interface FreebititAuthResponse {
|
||||||
@ -11,14 +11,14 @@ export interface FreebititAuthResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
statusCode: string;
|
statusCode: string;
|
||||||
};
|
};
|
||||||
authKey: string; // Token for subsequent API calls
|
authKey: string; // Token for subsequent API calls
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititAccountDetailsRequest {
|
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: "MASTER" | "MVNO" | 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: "MASTER" | "MVNO" | string;
|
||||||
account: string | number;
|
account: string | number;
|
||||||
state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string;
|
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | 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?: "standard" | "nano" | "micro" | "esim" | 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: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
|
||||||
date: string | number;
|
date: string | number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| Array<{
|
| Array<{
|
||||||
kind: 'MASTER' | 'MVNO' | string;
|
kind: "MASTER" | "MVNO" | string;
|
||||||
account: string | number;
|
account: string | number;
|
||||||
state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string;
|
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
|
||||||
startDate?: string | number;
|
startDate?: string | number;
|
||||||
relationCode?: string;
|
relationCode?: string;
|
||||||
resultCode?: string | number;
|
resultCode?: string | number;
|
||||||
@ -67,17 +67,17 @@ export interface FreebititAccountDetailsResponse {
|
|||||||
imsi?: string | number;
|
imsi?: string | number;
|
||||||
eid?: string;
|
eid?: string;
|
||||||
contractLine?: string;
|
contractLine?: string;
|
||||||
size?: 'standard' | 'nano' | 'micro' | 'esim' | string;
|
size?: "standard" | "nano" | "micro" | "esim" | 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: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
|
||||||
date: string | number;
|
date: string | number;
|
||||||
};
|
};
|
||||||
}>
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititTrafficInfoRequest {
|
export interface FreebititTrafficInfoRequest {
|
||||||
@ -93,9 +93,9 @@ export interface FreebititTrafficInfoResponse {
|
|||||||
};
|
};
|
||||||
account: string;
|
account: string;
|
||||||
traffic: {
|
traffic: {
|
||||||
today: string; // Today's usage in KB
|
today: string; // Today's usage in KB
|
||||||
inRecentDays: string; // Comma-separated recent days usage
|
inRecentDays: string; // Comma-separated recent days usage
|
||||||
blackList: string; // 10=blacklisted, 20=not blacklisted
|
blackList: string; // 10=blacklisted, 20=not blacklisted
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,12 +106,12 @@ export interface FreebititTopUpRequest {
|
|||||||
// - PA04-04 (/master/addSpec/): MB units (string recommended by spec)
|
// - PA04-04 (/master/addSpec/): MB units (string recommended by spec)
|
||||||
// - PA05-22 (/mvno/eachQuota/): KB units (string recommended by spec)
|
// - PA05-22 (/mvno/eachQuota/): KB units (string recommended by spec)
|
||||||
quota: number | string;
|
quota: number | string;
|
||||||
quotaCode?: string; // Campaign code
|
quotaCode?: string; // Campaign code
|
||||||
expire?: string; // YYYYMMDD format
|
expire?: string; // YYYYMMDD format
|
||||||
// For PA04-04 addSpec
|
// For PA04-04 addSpec
|
||||||
kind?: string; // e.g. 'MVNO' (required by /master/addSpec/)
|
kind?: string; // e.g. 'MVNO' (required by /master/addSpec/)
|
||||||
// For PA05-22 eachQuota
|
// For PA05-22 eachQuota
|
||||||
runTime?: string; // YYYYMMDD or YYYYMMDDhhmmss
|
runTime?: string; // YYYYMMDD or YYYYMMDDhhmmss
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititTopUpResponse {
|
export interface FreebititTopUpResponse {
|
||||||
@ -128,12 +128,12 @@ export interface FreebititAddSpecRequest {
|
|||||||
account: string;
|
account: string;
|
||||||
kind?: string; // Required by PA04-04 (/master/addSpec/), e.g. 'MVNO'
|
kind?: string; // Required by PA04-04 (/master/addSpec/), e.g. 'MVNO'
|
||||||
// Feature flags: 10 = enabled, 20 = disabled
|
// Feature flags: 10 = enabled, 20 = disabled
|
||||||
voiceMail?: '10' | '20';
|
voiceMail?: "10" | "20";
|
||||||
voicemail?: '10' | '20';
|
voicemail?: "10" | "20";
|
||||||
callWaiting?: '10' | '20';
|
callWaiting?: "10" | "20";
|
||||||
callwaiting?: '10' | '20';
|
callwaiting?: "10" | "20";
|
||||||
worldWing?: '10' | '20';
|
worldWing?: "10" | "20";
|
||||||
worldwing?: '10' | '20';
|
worldwing?: "10" | "20";
|
||||||
contractLine?: string; // '4G' or '5G'
|
contractLine?: string; // '4G' or '5G'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,8 +148,8 @@ export interface FreebititAddSpecResponse {
|
|||||||
export interface FreebititQuotaHistoryRequest {
|
export interface FreebititQuotaHistoryRequest {
|
||||||
authKey: string;
|
authKey: string;
|
||||||
account: string;
|
account: string;
|
||||||
fromDate: string; // YYYYMMDD
|
fromDate: string; // YYYYMMDD
|
||||||
toDate: string; // YYYYMMDD
|
toDate: string; // YYYYMMDD
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititQuotaHistoryResponse {
|
export interface FreebititQuotaHistoryResponse {
|
||||||
@ -173,8 +173,8 @@ export interface FreebititPlanChangeRequest {
|
|||||||
authKey: string;
|
authKey: string;
|
||||||
account: string;
|
account: string;
|
||||||
planCode: string;
|
planCode: string;
|
||||||
globalip?: '0' | '1'; // 0=no IP, 1=assign global IP
|
globalip?: "0" | "1"; // 0=no IP, 1=assign global IP
|
||||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititPlanChangeResponse {
|
export interface FreebititPlanChangeResponse {
|
||||||
@ -190,7 +190,7 @@ export interface FreebititPlanChangeResponse {
|
|||||||
export interface FreebititCancelPlanRequest {
|
export interface FreebititCancelPlanRequest {
|
||||||
authKey: string;
|
authKey: string;
|
||||||
account: string;
|
account: string;
|
||||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititCancelPlanResponse {
|
export interface FreebititCancelPlanResponse {
|
||||||
@ -219,7 +219,7 @@ export interface FreebititEsimAddAccountRequest {
|
|||||||
aladinOperated?: string;
|
aladinOperated?: string;
|
||||||
account: string;
|
account: string;
|
||||||
eid: string;
|
eid: string;
|
||||||
addKind: 'N' | 'R'; // N = new, R = reissue
|
addKind: "N" | "R"; // N = new, R = reissue
|
||||||
createType?: string;
|
createType?: string;
|
||||||
simKind?: string;
|
simKind?: string;
|
||||||
planCode?: string;
|
planCode?: string;
|
||||||
@ -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: "new" | "reissue" | "exchange" | 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: "esim" | string;
|
||||||
repAccount?: string;
|
repAccount?: string;
|
||||||
size?: string;
|
size?: string;
|
||||||
addKind?: 'N' | 'R' | string; // e.g., 'R' for reissue
|
addKind?: "N" | "R" | string; // e.g., 'R' for reissue
|
||||||
oldEid?: string;
|
oldEid?: string;
|
||||||
oldProductNumber?: string;
|
oldProductNumber?: string;
|
||||||
mnp?: {
|
mnp?: {
|
||||||
@ -285,9 +285,9 @@ export interface SimDetails {
|
|||||||
imsi?: string;
|
imsi?: string;
|
||||||
eid?: string;
|
eid?: string;
|
||||||
planCode: string;
|
planCode: string;
|
||||||
status: 'active' | 'suspended' | 'cancelled' | 'pending';
|
status: "active" | "suspended" | "cancelled" | "pending";
|
||||||
simType: 'physical' | 'esim';
|
simType: "physical" | "esim";
|
||||||
size: 'standard' | 'nano' | 'micro' | 'esim';
|
size: "standard" | "nano" | "micro" | "esim";
|
||||||
hasVoice: boolean;
|
hasVoice: boolean;
|
||||||
hasSms: boolean;
|
hasSms: boolean;
|
||||||
remainingQuotaKb: number;
|
remainingQuotaKb: number;
|
||||||
|
|||||||
@ -128,7 +128,9 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
|||||||
const event = payloadData as Record<string, unknown>;
|
const event = payloadData as Record<string, unknown>;
|
||||||
const payload = ((): Record<string, unknown> | undefined => {
|
const payload = ((): Record<string, unknown> | undefined => {
|
||||||
const p = event?.["payload"];
|
const p = event?.["payload"];
|
||||||
return typeof p === "object" && p != null ? (p as Record<string, unknown>) : undefined;
|
return typeof p === "object" && p != null
|
||||||
|
? (p as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Only check parsed payload
|
// Only check parsed payload
|
||||||
|
|||||||
@ -449,7 +449,9 @@ export class WhmcsConnectionService {
|
|||||||
/**
|
/**
|
||||||
* Add a manual payment to an invoice
|
* Add a manual payment to an invoice
|
||||||
*/
|
*/
|
||||||
async addInvoicePayment(params: WhmcsAddInvoicePaymentParams): Promise<WhmcsAddInvoicePaymentResponse> {
|
async addInvoicePayment(
|
||||||
|
params: WhmcsAddInvoicePaymentParams
|
||||||
|
): Promise<WhmcsAddInvoicePaymentResponse> {
|
||||||
return this.makeRequest("AddInvoicePayment", params);
|
return this.makeRequest("AddInvoicePayment", params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,14 +5,14 @@ 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,
|
WhmcsGetInvoicesParams,
|
||||||
WhmcsCreateInvoiceParams,
|
WhmcsCreateInvoiceParams,
|
||||||
WhmcsCreateInvoiceResponse,
|
WhmcsCreateInvoiceResponse,
|
||||||
WhmcsUpdateInvoiceParams,
|
WhmcsUpdateInvoiceParams,
|
||||||
WhmcsUpdateInvoiceResponse,
|
WhmcsUpdateInvoiceResponse,
|
||||||
WhmcsCapturePaymentParams,
|
WhmcsCapturePaymentParams,
|
||||||
WhmcsCapturePaymentResponse
|
WhmcsCapturePaymentResponse,
|
||||||
} from "../types/whmcs-api.types";
|
} from "../types/whmcs-api.types";
|
||||||
|
|
||||||
export interface InvoiceFilters {
|
export interface InvoiceFilters {
|
||||||
@ -250,9 +250,9 @@ export class WhmcsInvoiceService {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}): Promise<{ id: number; number: string; total: number; status: string }> {
|
}): Promise<{ id: number; number: string; total: number; status: string }> {
|
||||||
try {
|
try {
|
||||||
const dueDateStr = params.dueDate
|
const dueDateStr = params.dueDate
|
||||||
? params.dueDate.toISOString().split('T')[0]
|
? params.dueDate.toISOString().split("T")[0]
|
||||||
: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; // 7 days from now
|
: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; // 7 days from now
|
||||||
|
|
||||||
const whmcsParams: WhmcsCreateInvoiceParams = {
|
const whmcsParams: WhmcsCreateInvoiceParams = {
|
||||||
userid: params.clientId,
|
userid: params.clientId,
|
||||||
@ -297,7 +297,14 @@ export class WhmcsInvoiceService {
|
|||||||
*/
|
*/
|
||||||
async updateInvoice(params: {
|
async updateInvoice(params: {
|
||||||
invoiceId: number;
|
invoiceId: number;
|
||||||
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending";
|
status?:
|
||||||
|
| "Draft"
|
||||||
|
| "Unpaid"
|
||||||
|
| "Paid"
|
||||||
|
| "Cancelled"
|
||||||
|
| "Refunded"
|
||||||
|
| "Collections"
|
||||||
|
| "Payment Pending";
|
||||||
dueDate?: Date;
|
dueDate?: Date;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}): Promise<{ success: boolean; message?: string }> {
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
@ -305,7 +312,7 @@ export class WhmcsInvoiceService {
|
|||||||
const whmcsParams: WhmcsUpdateInvoiceParams = {
|
const whmcsParams: WhmcsUpdateInvoiceParams = {
|
||||||
invoiceid: params.invoiceId,
|
invoiceid: params.invoiceId,
|
||||||
status: params.status,
|
status: params.status,
|
||||||
duedate: params.dueDate ? params.dueDate.toISOString().split('T')[0] : undefined,
|
duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined,
|
||||||
notes: params.notes,
|
notes: params.notes,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -370,8 +377,10 @@ export class WhmcsInvoiceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Return user-friendly error message instead of technical API error
|
// Return user-friendly error message instead of technical API error
|
||||||
const userFriendlyError = this.getUserFriendlyPaymentError(response.message || response.error || 'Unknown payment error');
|
const userFriendlyError = this.getUserFriendlyPaymentError(
|
||||||
|
response.message || response.error || "Unknown payment error"
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: userFriendlyError,
|
error: userFriendlyError,
|
||||||
@ -385,7 +394,7 @@ export class WhmcsInvoiceService {
|
|||||||
|
|
||||||
// Return user-friendly error message for exceptions
|
// Return user-friendly error message for exceptions
|
||||||
const userFriendlyError = this.getUserFriendlyPaymentError(getErrorMessage(error));
|
const userFriendlyError = this.getUserFriendlyPaymentError(getErrorMessage(error));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: userFriendlyError,
|
error: userFriendlyError,
|
||||||
@ -404,27 +413,39 @@ export class WhmcsInvoiceService {
|
|||||||
const errorLower = technicalError.toLowerCase();
|
const errorLower = technicalError.toLowerCase();
|
||||||
|
|
||||||
// WHMCS API permission errors
|
// WHMCS API permission errors
|
||||||
if (errorLower.includes('invalid permissions') || errorLower.includes('not allowed')) {
|
if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) {
|
||||||
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
|
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentication/authorization errors
|
// Authentication/authorization errors
|
||||||
if (errorLower.includes('unauthorized') || errorLower.includes('forbidden') || errorLower.includes('403')) {
|
if (
|
||||||
|
errorLower.includes("unauthorized") ||
|
||||||
|
errorLower.includes("forbidden") ||
|
||||||
|
errorLower.includes("403")
|
||||||
|
) {
|
||||||
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
|
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network/timeout errors
|
// Network/timeout errors
|
||||||
if (errorLower.includes('timeout') || errorLower.includes('network') || errorLower.includes('connection')) {
|
if (
|
||||||
|
errorLower.includes("timeout") ||
|
||||||
|
errorLower.includes("network") ||
|
||||||
|
errorLower.includes("connection")
|
||||||
|
) {
|
||||||
return "Payment processing timed out. Please try again.";
|
return "Payment processing timed out. Please try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment method errors
|
// Payment method errors
|
||||||
if (errorLower.includes('payment method') || errorLower.includes('card') || errorLower.includes('insufficient funds')) {
|
if (
|
||||||
|
errorLower.includes("payment method") ||
|
||||||
|
errorLower.includes("card") ||
|
||||||
|
errorLower.includes("insufficient funds")
|
||||||
|
) {
|
||||||
return "Unable to process payment with your current payment method. Please check your payment details or try a different method.";
|
return "Unable to process payment with your current payment method. Please check your payment details or try a different method.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic API errors
|
// Generic API errors
|
||||||
if (errorLower.includes('api') || errorLower.includes('http') || errorLower.includes('error')) {
|
if (errorLower.includes("api") || errorLower.includes("http") || errorLower.includes("error")) {
|
||||||
return "Payment processing failed. Please try again or contact support if the issue persists.";
|
return "Payment processing failed. Please try again or contact support if the issue persists.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -96,7 +96,9 @@ export class WhmcsDataTransformer {
|
|||||||
// - Product names often contain "Activation Fee" or "Setup"
|
// - Product names often contain "Activation Fee" or "Setup"
|
||||||
const nameLower = (whmcsProduct.name || whmcsProduct.productname || "").toLowerCase();
|
const nameLower = (whmcsProduct.name || whmcsProduct.productname || "").toLowerCase();
|
||||||
const looksLikeActivation =
|
const looksLikeActivation =
|
||||||
nameLower.includes("activation fee") || nameLower.includes("activation") || nameLower.includes("setup");
|
nameLower.includes("activation fee") ||
|
||||||
|
nameLower.includes("activation") ||
|
||||||
|
nameLower.includes("setup");
|
||||||
|
|
||||||
if ((recurringAmount === 0 && firstPaymentAmount > 0) || looksLikeActivation) {
|
if ((recurringAmount === 0 && firstPaymentAmount > 0) || looksLikeActivation) {
|
||||||
normalizedCycle = "One-time";
|
normalizedCycle = "One-time";
|
||||||
|
|||||||
@ -362,7 +362,14 @@ export interface WhmcsPaymentGatewaysResponse {
|
|||||||
// CreateInvoice API Types
|
// CreateInvoice API Types
|
||||||
export interface WhmcsCreateInvoiceParams {
|
export interface WhmcsCreateInvoiceParams {
|
||||||
userid: number;
|
userid: number;
|
||||||
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending";
|
status?:
|
||||||
|
| "Draft"
|
||||||
|
| "Unpaid"
|
||||||
|
| "Paid"
|
||||||
|
| "Cancelled"
|
||||||
|
| "Refunded"
|
||||||
|
| "Collections"
|
||||||
|
| "Payment Pending";
|
||||||
sendnotification?: boolean;
|
sendnotification?: boolean;
|
||||||
paymentmethod?: string;
|
paymentmethod?: string;
|
||||||
taxrate?: number;
|
taxrate?: number;
|
||||||
@ -390,7 +397,14 @@ export interface WhmcsCreateInvoiceResponse {
|
|||||||
// UpdateInvoice API Types
|
// UpdateInvoice API Types
|
||||||
export interface WhmcsUpdateInvoiceParams {
|
export interface WhmcsUpdateInvoiceParams {
|
||||||
invoiceid: number;
|
invoiceid: number;
|
||||||
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending";
|
status?:
|
||||||
|
| "Draft"
|
||||||
|
| "Unpaid"
|
||||||
|
| "Paid"
|
||||||
|
| "Cancelled"
|
||||||
|
| "Refunded"
|
||||||
|
| "Collections"
|
||||||
|
| "Payment Pending";
|
||||||
duedate?: string; // YYYY-MM-DD format
|
duedate?: string; // YYYY-MM-DD format
|
||||||
notes?: string;
|
notes?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@ -403,7 +417,7 @@ export interface WhmcsUpdateInvoiceResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CapturePayment API Types
|
// CapturePayment API Types
|
||||||
export interface WhmcsCapturePaymentParams {
|
export interface WhmcsCapturePaymentParams {
|
||||||
invoiceid: number;
|
invoiceid: number;
|
||||||
cvv?: string;
|
cvv?: string;
|
||||||
@ -460,4 +474,4 @@ export interface WhmcsAddInvoicePaymentParams {
|
|||||||
export interface WhmcsAddInvoicePaymentResponse {
|
export interface WhmcsAddInvoicePaymentResponse {
|
||||||
result: "success" | "error";
|
result: "success" | "error";
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
9
apps/bff/src/vendors/whmcs/whmcs.service.ts
vendored
9
apps/bff/src/vendors/whmcs/whmcs.service.ts
vendored
@ -332,7 +332,14 @@ export class WhmcsService {
|
|||||||
*/
|
*/
|
||||||
async updateInvoice(params: {
|
async updateInvoice(params: {
|
||||||
invoiceId: number;
|
invoiceId: number;
|
||||||
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending";
|
status?:
|
||||||
|
| "Draft"
|
||||||
|
| "Unpaid"
|
||||||
|
| "Paid"
|
||||||
|
| "Cancelled"
|
||||||
|
| "Refunded"
|
||||||
|
| "Collections"
|
||||||
|
| "Payment Pending";
|
||||||
dueDate?: Date;
|
dueDate?: Date;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}): Promise<{ success: boolean; message?: string }> {
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// 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";
|
||||||
|
|
||||||
const root = new URL('..', import.meta.url).pathname; // apps/portal
|
const root = new URL("..", import.meta.url).pathname; // apps/portal
|
||||||
const nextDir = join(root, '.next');
|
const nextDir = join(root, ".next");
|
||||||
const routesManifestPath = join(nextDir, 'routes-manifest.json');
|
const routesManifestPath = join(nextDir, "routes-manifest.json");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mkdirSync(nextDir, { recursive: true });
|
mkdirSync(nextDir, { recursive: true });
|
||||||
@ -13,17 +13,15 @@ try {
|
|||||||
const minimalManifest = {
|
const minimalManifest = {
|
||||||
version: 5,
|
version: 5,
|
||||||
pages404: true,
|
pages404: true,
|
||||||
basePath: '',
|
basePath: "",
|
||||||
redirects: [],
|
redirects: [],
|
||||||
rewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
|
rewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
|
||||||
headers: [],
|
headers: [],
|
||||||
};
|
};
|
||||||
writeFileSync(routesManifestPath, JSON.stringify(minimalManifest, null, 2));
|
writeFileSync(routesManifestPath, JSON.stringify(minimalManifest, null, 2));
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[dev-prep] Created minimal .next/routes-manifest.json');
|
console.log("[dev-prep] Created minimal .next/routes-manifest.json");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
console.warn("[dev-prep] Failed to prepare Next dev files:", err?.message || err);
|
||||||
console.warn('[dev-prep] Failed to prepare Next dev files:', err?.message || err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,8 +31,8 @@ export default function CatalogPage() {
|
|||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-600 max-w-4xl mx-auto leading-relaxed">
|
<p className="text-xl text-gray-600 max-w-4xl mx-auto leading-relaxed">
|
||||||
Discover high-speed internet, wide range of mobile data options, and secure VPN services. Each
|
Discover high-speed internet, wide range of mobile data options, and secure VPN
|
||||||
solution is personalized based on your location and account eligibility.
|
services. Each solution is personalized based on your location and account eligibility.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -273,12 +273,16 @@ export default function SimPlansPage() {
|
|||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<PhoneIcon className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-voice" ? "scale-110" : ""}`} />
|
<PhoneIcon
|
||||||
|
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-voice" ? "scale-110" : ""}`}
|
||||||
|
/>
|
||||||
Data + SMS/Voice
|
Data + SMS/Voice
|
||||||
{plansByType.DataSmsVoice.length > 0 && (
|
{plansByType.DataSmsVoice.length > 0 && (
|
||||||
<span className={`bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
|
<span
|
||||||
activeTab === "data-voice" ? "scale-110 bg-blue-200" : ""
|
className={`bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
|
||||||
}`}>
|
activeTab === "data-voice" ? "scale-110 bg-blue-200" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{plansByType.DataSmsVoice.length}
|
{plansByType.DataSmsVoice.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -291,12 +295,16 @@ export default function SimPlansPage() {
|
|||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<GlobeAltIcon className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-only" ? "scale-110" : ""}`} />
|
<GlobeAltIcon
|
||||||
|
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-only" ? "scale-110" : ""}`}
|
||||||
|
/>
|
||||||
Data Only
|
Data Only
|
||||||
{plansByType.DataOnly.length > 0 && (
|
{plansByType.DataOnly.length > 0 && (
|
||||||
<span className={`bg-purple-100 text-purple-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
|
<span
|
||||||
activeTab === "data-only" ? "scale-110 bg-purple-200" : ""
|
className={`bg-purple-100 text-purple-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
|
||||||
}`}>
|
activeTab === "data-only" ? "scale-110 bg-purple-200" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{plansByType.DataOnly.length}
|
{plansByType.DataOnly.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -309,12 +317,16 @@ export default function SimPlansPage() {
|
|||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<PhoneIcon className={`h-5 w-5 transition-transform duration-300 ${activeTab === "voice-only" ? "scale-110" : ""}`} />
|
<PhoneIcon
|
||||||
|
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "voice-only" ? "scale-110" : ""}`}
|
||||||
|
/>
|
||||||
Voice Only
|
Voice Only
|
||||||
{plansByType.VoiceOnly.length > 0 && (
|
{plansByType.VoiceOnly.length > 0 && (
|
||||||
<span className={`bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
|
<span
|
||||||
activeTab === "voice-only" ? "scale-110 bg-orange-200" : ""
|
className={`bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
|
||||||
}`}>
|
activeTab === "voice-only" ? "scale-110 bg-orange-200" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{plansByType.VoiceOnly.length}
|
{plansByType.VoiceOnly.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -325,11 +337,13 @@ export default function SimPlansPage() {
|
|||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div className="min-h-[400px] relative">
|
<div className="min-h-[400px] relative">
|
||||||
<div className={`transition-all duration-500 ease-in-out ${
|
<div
|
||||||
activeTab === "data-voice"
|
className={`transition-all duration-500 ease-in-out ${
|
||||||
? "opacity-100 translate-y-0"
|
activeTab === "data-voice"
|
||||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
? "opacity-100 translate-y-0"
|
||||||
}`}>
|
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{activeTab === "data-voice" && (
|
{activeTab === "data-voice" && (
|
||||||
<PlanTypeSection
|
<PlanTypeSection
|
||||||
title="Data + SMS/Voice Plans"
|
title="Data + SMS/Voice Plans"
|
||||||
@ -341,11 +355,13 @@ export default function SimPlansPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`transition-all duration-500 ease-in-out ${
|
<div
|
||||||
activeTab === "data-only"
|
className={`transition-all duration-500 ease-in-out ${
|
||||||
? "opacity-100 translate-y-0"
|
activeTab === "data-only"
|
||||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
? "opacity-100 translate-y-0"
|
||||||
}`}>
|
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{activeTab === "data-only" && (
|
{activeTab === "data-only" && (
|
||||||
<PlanTypeSection
|
<PlanTypeSection
|
||||||
title="Data Only Plans"
|
title="Data Only Plans"
|
||||||
@ -357,11 +373,13 @@ export default function SimPlansPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`transition-all duration-500 ease-in-out ${
|
<div
|
||||||
activeTab === "voice-only"
|
className={`transition-all duration-500 ease-in-out ${
|
||||||
? "opacity-100 translate-y-0"
|
activeTab === "voice-only"
|
||||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
? "opacity-100 translate-y-0"
|
||||||
}`}>
|
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{activeTab === "voice-only" && (
|
{activeTab === "voice-only" && (
|
||||||
<PlanTypeSection
|
<PlanTypeSection
|
||||||
title="Voice Only Plans"
|
title="Voice Only Plans"
|
||||||
@ -437,29 +455,46 @@ export default function SimPlansPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<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">Minimum 3 full billing months required. First month (sign-up to end of month) is free and doesn't count toward contract.</p>
|
<p className="text-blue-800">
|
||||||
|
Minimum 3 full billing months required. First month (sign-up to end of month) is
|
||||||
|
free and doesn't count toward contract.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-blue-900">Billing Cycle</div>
|
<div className="font-medium text-blue-900">Billing Cycle</div>
|
||||||
<p className="text-blue-800">Monthly billing from 1st to end of month. Regular billing starts on 1st of following month after sign-up.</p>
|
<p className="text-blue-800">
|
||||||
|
Monthly billing from 1st to end of month. Regular billing starts on 1st of
|
||||||
|
following month after sign-up.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-blue-900">Cancellation</div>
|
<div className="font-medium text-blue-900">Cancellation</div>
|
||||||
<p className="text-blue-800">Can be requested online after 3rd month. Service terminates at end of billing cycle.</p>
|
<p className="text-blue-800">
|
||||||
|
Can be requested online after 3rd month. Service terminates at end of billing
|
||||||
|
cycle.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-blue-900">Plan Changes</div>
|
<div className="font-medium text-blue-900">Plan Changes</div>
|
||||||
<p className="text-blue-800">Data plan switching is free and takes effect next month. Voice plan changes require new SIM and cancellation policies apply.</p>
|
<p className="text-blue-800">
|
||||||
|
Data plan switching is free and takes effect next month. Voice plan changes
|
||||||
|
require new SIM and cancellation policies apply.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-blue-900">Calling/SMS Charges</div>
|
<div className="font-medium text-blue-900">Calling/SMS Charges</div>
|
||||||
<p className="text-blue-800">Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing cycle.</p>
|
<p className="text-blue-800">
|
||||||
|
Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing
|
||||||
|
cycle.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-blue-900">SIM Replacement</div>
|
<div className="font-medium text-blue-900">SIM Replacement</div>
|
||||||
<p className="text-blue-800">Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.</p>
|
<p className="text-blue-800">
|
||||||
|
Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,21 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
import { ClipboardDocumentCheckIcon, CheckCircleIcon, WifiIcon, DevicePhoneMobileIcon, LockClosedIcon, CubeIcon, StarIcon, WrenchScrewdriverIcon, PlusIcon, BoltIcon, ExclamationTriangleIcon, EnvelopeIcon, PhoneIcon } from "@heroicons/react/24/outline";
|
import {
|
||||||
|
ClipboardDocumentCheckIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
WifiIcon,
|
||||||
|
DevicePhoneMobileIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
CubeIcon,
|
||||||
|
StarIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
PlusIcon,
|
||||||
|
BoltIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
import { SubCard } from "@/components/ui/sub-card";
|
import { SubCard } from "@/components/ui/sub-card";
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
import { StatusPill } from "@/components/ui/status-pill";
|
||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
@ -190,8 +204,8 @@ export default function OrderStatusPage() {
|
|||||||
Order Submitted Successfully!
|
Order Submitted Successfully!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-green-800 mb-3">
|
<p className="text-green-800 mb-3">
|
||||||
Your order has been created and submitted for processing. We will notify you as
|
Your order has been created and submitted for processing. We will notify you as soon
|
||||||
soon as it's approved and ready for activation.
|
as it's approved and ready for activation.
|
||||||
</p>
|
</p>
|
||||||
<div className="text-sm text-green-700">
|
<div className="text-sm text-green-700">
|
||||||
<p className="mb-1">
|
<p className="mb-1">
|
||||||
@ -210,7 +224,7 @@ export default function OrderStatusPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status Section - Moved to top */}
|
{/* Status Section - Moved to top */}
|
||||||
{data && (
|
{data &&
|
||||||
(() => {
|
(() => {
|
||||||
const statusInfo = getDetailedStatusInfo(
|
const statusInfo = getDetailedStatusInfo(
|
||||||
data.status,
|
data.status,
|
||||||
@ -228,11 +242,9 @@ export default function OrderStatusPage() {
|
|||||||
: "neutral";
|
: "neutral";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubCard
|
<SubCard
|
||||||
className="mb-9"
|
className="mb-9"
|
||||||
header={
|
header={<h3 className="text-xl font-bold text-gray-900">Status</h3>}
|
||||||
<h3 className="text-xl font-bold text-gray-900">Status</h3>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
||||||
<div className="text-gray-700 text-lg sm:text-xl">{statusInfo.description}</div>
|
<div className="text-gray-700 text-lg sm:text-xl">{statusInfo.description}</div>
|
||||||
@ -241,7 +253,7 @@ export default function OrderStatusPage() {
|
|||||||
variant={statusVariant as "info" | "success" | "warning" | "error"}
|
variant={statusVariant as "info" | "success" | "warning" | "error"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Highlighted Next Steps Section */}
|
{/* Highlighted Next Steps Section */}
|
||||||
{statusInfo.nextAction && (
|
{statusInfo.nextAction && (
|
||||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4 mb-4 shadow-sm">
|
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4 mb-4 shadow-sm">
|
||||||
@ -252,7 +264,7 @@ export default function OrderStatusPage() {
|
|||||||
<p className="text-blue-800 text-base leading-relaxed">{statusInfo.nextAction}</p>
|
<p className="text-blue-800 text-base leading-relaxed">{statusInfo.nextAction}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{statusInfo.timeline && (
|
{statusInfo.timeline && (
|
||||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
@ -262,15 +274,16 @@ export default function OrderStatusPage() {
|
|||||||
)}
|
)}
|
||||||
</SubCard>
|
</SubCard>
|
||||||
);
|
);
|
||||||
})()
|
})()}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Combined Service Overview and Products */}
|
{/* Combined Service Overview and Products */}
|
||||||
{data && (
|
{data && (
|
||||||
<div className="bg-white border rounded-2xl p-4 sm:p-8 mb-8">
|
<div className="bg-white border rounded-2xl p-4 sm:p-8 mb-8">
|
||||||
{/* Service Header */}
|
{/* Service Header */}
|
||||||
<div className="flex flex-col sm:flex-row items-start gap-4 sm:gap-6 mb-6">
|
<div className="flex flex-col sm:flex-row items-start gap-4 sm:gap-6 mb-6">
|
||||||
<div className="flex items-center text-3xl sm:text-4xl">{getServiceTypeIcon(data.orderType)}</div>
|
<div className="flex items-center text-3xl sm:text-4xl">
|
||||||
|
{getServiceTypeIcon(data.orderType)}
|
||||||
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2 flex items-center">
|
<h2 className="text-2xl font-bold text-gray-900 mb-2 flex items-center">
|
||||||
{data.orderType} Service
|
{data.orderType} Service
|
||||||
@ -341,7 +354,7 @@ export default function OrderStatusPage() {
|
|||||||
const bIsService = b.product.itemClass === "Service";
|
const bIsService = b.product.itemClass === "Service";
|
||||||
const aIsInstallation = a.product.itemClass === "Installation";
|
const aIsInstallation = a.product.itemClass === "Installation";
|
||||||
const bIsInstallation = b.product.itemClass === "Installation";
|
const bIsInstallation = b.product.itemClass === "Installation";
|
||||||
|
|
||||||
if (aIsService && !bIsService) return -1;
|
if (aIsService && !bIsService) return -1;
|
||||||
if (!aIsService && bIsService) return 1;
|
if (!aIsService && bIsService) return 1;
|
||||||
if (aIsInstallation && !bIsInstallation) return -1;
|
if (aIsInstallation && !bIsInstallation) return -1;
|
||||||
@ -349,111 +362,116 @@ export default function OrderStatusPage() {
|
|||||||
return 0;
|
return 0;
|
||||||
})
|
})
|
||||||
.map(item => {
|
.map(item => {
|
||||||
// Use the actual Item_Class__c values from Salesforce documentation
|
// Use the actual Item_Class__c values from Salesforce documentation
|
||||||
const itemClass = item.product.itemClass;
|
const itemClass = item.product.itemClass;
|
||||||
|
|
||||||
// Get appropriate icon and color based on item type and billing cycle
|
// Get appropriate icon and color based on item type and billing cycle
|
||||||
const getItemTypeInfo = () => {
|
const getItemTypeInfo = () => {
|
||||||
const isMonthly = item.product.billingCycle === "Monthly";
|
const isMonthly = item.product.billingCycle === "Monthly";
|
||||||
const isService = itemClass === "Service";
|
const isService = itemClass === "Service";
|
||||||
const isInstallation = itemClass === "Installation";
|
const isInstallation = itemClass === "Installation";
|
||||||
|
|
||||||
if (isService && isMonthly) {
|
|
||||||
// Main service products - Blue theme
|
|
||||||
return {
|
|
||||||
icon: <StarIcon className="h-4 w-4" />,
|
|
||||||
bg: "bg-blue-50 border-blue-200",
|
|
||||||
iconBg: "bg-blue-100 text-blue-600",
|
|
||||||
label: itemClass || "Service",
|
|
||||||
labelColor: "text-blue-600",
|
|
||||||
};
|
|
||||||
} else if (isInstallation) {
|
|
||||||
// Installation items - Green theme
|
|
||||||
return {
|
|
||||||
icon: <WrenchScrewdriverIcon className="h-4 w-4" />,
|
|
||||||
bg: "bg-green-50 border-green-200",
|
|
||||||
iconBg: "bg-green-100 text-green-600",
|
|
||||||
label: itemClass || "Installation",
|
|
||||||
labelColor: "text-green-600",
|
|
||||||
};
|
|
||||||
} else if (isMonthly) {
|
|
||||||
// Other monthly products - Blue theme
|
|
||||||
return {
|
|
||||||
icon: <StarIcon className="h-4 w-4" />,
|
|
||||||
bg: "bg-blue-50 border-blue-200",
|
|
||||||
iconBg: "bg-blue-100 text-blue-600",
|
|
||||||
label: itemClass || "Service",
|
|
||||||
labelColor: "text-blue-600",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// One-time products - Orange theme
|
|
||||||
return {
|
|
||||||
icon: <CubeIcon className="h-4 w-4" />,
|
|
||||||
bg: "bg-orange-50 border-orange-200",
|
|
||||||
iconBg: "bg-orange-100 text-orange-600",
|
|
||||||
label: itemClass || "Add-on",
|
|
||||||
labelColor: "text-orange-600",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeInfo = getItemTypeInfo();
|
if (isService && isMonthly) {
|
||||||
|
// Main service products - Blue theme
|
||||||
|
return {
|
||||||
|
icon: <StarIcon className="h-4 w-4" />,
|
||||||
|
bg: "bg-blue-50 border-blue-200",
|
||||||
|
iconBg: "bg-blue-100 text-blue-600",
|
||||||
|
label: itemClass || "Service",
|
||||||
|
labelColor: "text-blue-600",
|
||||||
|
};
|
||||||
|
} else if (isInstallation) {
|
||||||
|
// Installation items - Green theme
|
||||||
|
return {
|
||||||
|
icon: <WrenchScrewdriverIcon className="h-4 w-4" />,
|
||||||
|
bg: "bg-green-50 border-green-200",
|
||||||
|
iconBg: "bg-green-100 text-green-600",
|
||||||
|
label: itemClass || "Installation",
|
||||||
|
labelColor: "text-green-600",
|
||||||
|
};
|
||||||
|
} else if (isMonthly) {
|
||||||
|
// Other monthly products - Blue theme
|
||||||
|
return {
|
||||||
|
icon: <StarIcon className="h-4 w-4" />,
|
||||||
|
bg: "bg-blue-50 border-blue-200",
|
||||||
|
iconBg: "bg-blue-100 text-blue-600",
|
||||||
|
label: itemClass || "Service",
|
||||||
|
labelColor: "text-blue-600",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// One-time products - Orange theme
|
||||||
|
return {
|
||||||
|
icon: <CubeIcon className="h-4 w-4" />,
|
||||||
|
bg: "bg-orange-50 border-orange-200",
|
||||||
|
iconBg: "bg-orange-100 text-orange-600",
|
||||||
|
label: itemClass || "Add-on",
|
||||||
|
labelColor: "text-orange-600",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const typeInfo = getItemTypeInfo();
|
||||||
<div key={item.id} className={`rounded-lg p-4 border ${typeInfo.bg} transition-shadow hover:shadow-sm`}>
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start gap-3">
|
|
||||||
<div className="flex items-start gap-3 flex-1">
|
|
||||||
<div
|
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${typeInfo.iconBg} flex-shrink-0`}
|
|
||||||
>
|
|
||||||
{typeInfo.icon}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
return (
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div
|
||||||
<h3 className="font-semibold text-gray-900 truncate flex-1 min-w-0">
|
key={item.id}
|
||||||
{item.product.name}
|
className={`rounded-lg p-4 border ${typeInfo.bg} transition-shadow hover:shadow-sm`}
|
||||||
</h3>
|
>
|
||||||
<span
|
<div className="flex flex-col sm:flex-row justify-between items-start gap-3">
|
||||||
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor}`}
|
<div className="flex items-start gap-3 flex-1">
|
||||||
>
|
<div
|
||||||
{typeInfo.label}
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${typeInfo.iconBg} flex-shrink-0`}
|
||||||
</span>
|
>
|
||||||
|
{typeInfo.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-600">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="font-medium">{item.product.billingCycle}</span>
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
{item.quantity > 1 && <span>Qty: {item.quantity}</span>}
|
<h3 className="font-semibold text-gray-900 truncate flex-1 min-w-0">
|
||||||
{item.product.itemClass && (
|
{item.product.name}
|
||||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded">
|
</h3>
|
||||||
{item.product.itemClass}
|
<span
|
||||||
|
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor}`}
|
||||||
|
>
|
||||||
|
{typeInfo.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-600">
|
||||||
|
<span className="font-medium">{item.product.billingCycle}</span>
|
||||||
|
{item.quantity > 1 && <span>Qty: {item.quantity}</span>}
|
||||||
|
{item.product.itemClass && (
|
||||||
|
<span className="text-xs bg-gray-100 px-2 py-1 rounded">
|
||||||
|
{item.product.itemClass}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-left sm:text-right ml-0 sm:ml-3 mt-2 sm:mt-0 flex-shrink-0 sm:w-32">
|
<div className="text-left sm:text-right ml-0 sm:ml-3 mt-2 sm:mt-0 flex-shrink-0 sm:w-32">
|
||||||
{item.totalPrice && (
|
{item.totalPrice && (
|
||||||
<div className="font-semibold text-gray-900 tabular-nums">
|
<div className="font-semibold text-gray-900 tabular-nums">
|
||||||
¥{item.totalPrice.toLocaleString()}
|
¥{item.totalPrice.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Additional fees warning */}
|
{/* Additional fees warning */}
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-4">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-4">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-600 flex-shrink-0" />
|
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-600 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-yellow-900">Additional fees may apply</p>
|
<p className="text-sm font-medium text-yellow-900">
|
||||||
|
Additional fees may apply
|
||||||
|
</p>
|
||||||
<p className="text-xs text-yellow-800 mt-1">
|
<p className="text-xs text-yellow-800 mt-1">
|
||||||
Weekend installation (+¥3,000), express setup, or special configuration
|
Weekend installation (+¥3,000), express setup, or special configuration
|
||||||
charges may be added. We will contact you before applying any additional
|
charges may be added. We will contact you before applying any additional
|
||||||
@ -468,7 +486,6 @@ export default function OrderStatusPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Support Contact */}
|
{/* Support Contact */}
|
||||||
<SubCard title="Need Help?">
|
<SubCard title="Need Help?">
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||||
|
|||||||
@ -3,7 +3,14 @@
|
|||||||
import { useEffect, useState, Suspense } from "react";
|
import { useEffect, useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
import { ClipboardDocumentListIcon, CheckCircleIcon, WifiIcon, DevicePhoneMobileIcon, LockClosedIcon, CubeIcon } from "@heroicons/react/24/outline";
|
import {
|
||||||
|
ClipboardDocumentListIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
WifiIcon,
|
||||||
|
DevicePhoneMobileIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
CubeIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
import { StatusPill } from "@/components/ui/status-pill";
|
||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
|
|
||||||
@ -153,7 +160,7 @@ export default function OrdersPage() {
|
|||||||
order.itemsSummary.forEach(item => {
|
order.itemsSummary.forEach(item => {
|
||||||
const totalPrice = item.totalPrice || 0;
|
const totalPrice = item.totalPrice || 0;
|
||||||
const billingCycle = item.billingCycle?.toLowerCase() || "";
|
const billingCycle = item.billingCycle?.toLowerCase() || "";
|
||||||
|
|
||||||
if (billingCycle === "monthly") {
|
if (billingCycle === "monthly") {
|
||||||
monthlyTotal += totalPrice;
|
monthlyTotal += totalPrice;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -42,10 +42,10 @@ export default function SubscriptionDetailPage() {
|
|||||||
// Control what sections to show based on URL hash
|
// Control what sections to show based on URL hash
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateVisibility = () => {
|
const updateVisibility = () => {
|
||||||
const hash = typeof window !== 'undefined' ? window.location.hash : '';
|
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||||
const service = (searchParams.get('service') || '').toLowerCase();
|
const service = (searchParams.get("service") || "").toLowerCase();
|
||||||
const isSimContext = hash.includes('sim-management') || service === 'sim';
|
const isSimContext = hash.includes("sim-management") || service === "sim";
|
||||||
|
|
||||||
if (isSimContext) {
|
if (isSimContext) {
|
||||||
// Show only SIM management, hide invoices
|
// Show only SIM management, hide invoices
|
||||||
setShowInvoices(false);
|
setShowInvoices(false);
|
||||||
@ -57,9 +57,9 @@ export default function SubscriptionDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
updateVisibility();
|
updateVisibility();
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
window.addEventListener('hashchange', updateVisibility);
|
window.addEventListener("hashchange", updateVisibility);
|
||||||
return () => window.removeEventListener('hashchange', updateVisibility);
|
return () => window.removeEventListener("hashchange", updateVisibility);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
@ -221,7 +221,6 @@ export default function SubscriptionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -279,21 +278,23 @@ export default function SubscriptionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation tabs for SIM services - More visible and mobile-friendly */}
|
{/* Navigation tabs for SIM services - More visible and mobile-friendly */}
|
||||||
{subscription.productName.toLowerCase().includes('sim') && (
|
{subscription.productName.toLowerCase().includes("sim") && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
|
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Service Management</h3>
|
<h3 className="text-xl font-semibold text-gray-900">Service Management</h3>
|
||||||
<p className="text-sm text-gray-600 mt-1">Switch between billing and SIM management views</p>
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Switch between billing and SIM management views
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-100 rounded-xl p-2">
|
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-100 rounded-xl p-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||||
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[140px] text-center ${
|
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[140px] text-center ${
|
||||||
showSimManagement
|
showSimManagement
|
||||||
? 'bg-white text-blue-600 shadow-md hover:shadow-lg'
|
? "bg-white text-blue-600 shadow-md hover:shadow-lg"
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'
|
: "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ServerIcon className="h-4 w-4 inline mr-2" />
|
<ServerIcon className="h-4 w-4 inline mr-2" />
|
||||||
@ -302,9 +303,9 @@ export default function SubscriptionDetailPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={`/subscriptions/${subscriptionId}`}
|
href={`/subscriptions/${subscriptionId}`}
|
||||||
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[120px] text-center ${
|
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[120px] text-center ${
|
||||||
showInvoices
|
showInvoices
|
||||||
? 'bg-white text-blue-600 shadow-md hover:shadow-lg'
|
? "bg-white text-blue-600 shadow-md hover:shadow-lg"
|
||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'
|
: "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<DocumentTextIcon className="h-4 w-4 inline mr-2" />
|
<DocumentTextIcon className="h-4 w-4 inline mr-2" />
|
||||||
@ -317,186 +318,186 @@ export default function SubscriptionDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SIM Management Section - Only show when in SIM context and for SIM services */}
|
{/* SIM Management Section - Only show when in SIM context and for SIM services */}
|
||||||
{showSimManagement && subscription.productName.toLowerCase().includes('sim') && (
|
{showSimManagement && subscription.productName.toLowerCase().includes("sim") && (
|
||||||
<SimManagementSection subscriptionId={subscriptionId} />
|
<SimManagementSection subscriptionId={subscriptionId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Related Invoices (hidden when viewing SIM management directly) */}
|
{/* Related Invoices (hidden when viewing SIM management directly) */}
|
||||||
{showInvoices && (
|
{showInvoices && (
|
||||||
<div className="bg-white shadow rounded-lg">
|
<div className="bg-white shadow rounded-lg">
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<DocumentTextIcon className="h-6 w-6 text-blue-600 mr-2" />
|
<DocumentTextIcon className="h-6 w-6 text-blue-600 mr-2" />
|
||||||
<h3 className="text-lg font-medium text-gray-900">Related Invoices</h3>
|
<h3 className="text-lg font-medium text-gray-900">Related Invoices</h3>
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
Invoices containing charges for this subscription
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{invoicesLoading ? (
|
|
||||||
<div className="px-6 py-8 text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
|
||||||
<p className="mt-2 text-gray-600">Loading invoices...</p>
|
|
||||||
</div>
|
|
||||||
) : invoicesError ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-400" />
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-red-800">Error loading invoices</h3>
|
|
||||||
<p className="mt-1 text-sm text-red-600">
|
|
||||||
{invoicesError instanceof Error
|
|
||||||
? invoicesError.message
|
|
||||||
: "Failed to load related invoices"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : invoices.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<DocumentTextIcon className="mx-auto h-12 w-12 text-gray-400" />
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No invoices found</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
No invoices have been generated for this subscription yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{invoices.map(invoice => (
|
|
||||||
<div
|
|
||||||
key={invoice.id}
|
|
||||||
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-lg hover:border-blue-200 transition-all duration-200 group"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center flex-1">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{getInvoiceStatusIcon(invoice.status)}
|
|
||||||
</div>
|
|
||||||
<div className="ml-3 flex-1">
|
|
||||||
<h4 className="text-base font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
|
|
||||||
Invoice {invoice.number}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
Issued{" "}
|
|
||||||
{invoice.issuedAt &&
|
|
||||||
format(new Date(invoice.issuedAt), "MMM d, yyyy")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end space-y-2">
|
|
||||||
<span
|
|
||||||
className={`inline-flex px-3 py-1 text-sm font-medium rounded-full ${getInvoiceStatusColor(invoice.status)}`}
|
|
||||||
>
|
|
||||||
{invoice.status}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-bold text-gray-900">
|
|
||||||
{formatCurrency(invoice.total)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
<span className="block">
|
|
||||||
Due:{" "}
|
|
||||||
{invoice.dueDate
|
|
||||||
? format(new Date(invoice.dueDate), "MMM d, yyyy")
|
|
||||||
: "N/A"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
(window.location.href = `/billing/invoices/${invoice.id}`)
|
|
||||||
}
|
|
||||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
||||||
>
|
|
||||||
<DocumentTextIcon className="h-4 w-4 mr-2" />
|
|
||||||
View Invoice
|
|
||||||
<ArrowTopRightOnSquareIcon className="h-4 w-4 ml-2" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Invoices containing charges for this subscription
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{invoicesLoading ? (
|
||||||
{pagination && pagination.totalPages > 1 && (
|
<div className="px-6 py-8 text-center">
|
||||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
<p className="mt-2 text-gray-600">Loading invoices...</p>
|
||||||
<button
|
</div>
|
||||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
) : invoicesError ? (
|
||||||
disabled={currentPage === 1}
|
<div className="text-center py-12">
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-400" />
|
||||||
>
|
<h3 className="mt-2 text-sm font-medium text-red-800">Error loading invoices</h3>
|
||||||
Previous
|
<p className="mt-1 text-sm text-red-600">
|
||||||
</button>
|
{invoicesError instanceof Error
|
||||||
<button
|
? invoicesError.message
|
||||||
onClick={() =>
|
: "Failed to load related invoices"}
|
||||||
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
|
</p>
|
||||||
}
|
</div>
|
||||||
disabled={currentPage === pagination.totalPages}
|
) : invoices.length === 0 ? (
|
||||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
<div className="text-center py-12">
|
||||||
>
|
<DocumentTextIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
Next
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No invoices found</h3>
|
||||||
</button>
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
</div>
|
No invoices have been generated for this subscription yet.
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
</p>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-sm text-gray-700">
|
) : (
|
||||||
Showing{" "}
|
<>
|
||||||
<span className="font-medium">
|
<div className="p-6">
|
||||||
{(currentPage - 1) * itemsPerPage + 1}
|
<div className="space-y-4">
|
||||||
</span>{" "}
|
{invoices.map(invoice => (
|
||||||
to{" "}
|
<div
|
||||||
<span className="font-medium">
|
key={invoice.id}
|
||||||
{Math.min(currentPage * itemsPerPage, pagination.totalItems)}
|
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-lg hover:border-blue-200 transition-all duration-200 group"
|
||||||
</span>{" "}
|
>
|
||||||
of <span className="font-medium">{pagination.totalItems}</span> results
|
<div className="flex items-start justify-between">
|
||||||
</p>
|
<div className="flex items-center flex-1">
|
||||||
</div>
|
<div className="flex-shrink-0">
|
||||||
<div>
|
{getInvoiceStatusIcon(invoice.status)}
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
</div>
|
||||||
<button
|
<div className="ml-3 flex-1">
|
||||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
<h4 className="text-base font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
|
||||||
disabled={currentPage === 1}
|
Invoice {invoice.number}
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
</h4>
|
||||||
>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Previous
|
Issued{" "}
|
||||||
</button>
|
{invoice.issuedAt &&
|
||||||
{Array.from({ length: Math.min(pagination.totalPages, 5) }, (_, i) => {
|
format(new Date(invoice.issuedAt), "MMM d, yyyy")}
|
||||||
const startPage = Math.max(1, currentPage - 2);
|
</p>
|
||||||
const page = startPage + i;
|
</div>
|
||||||
if (page > pagination.totalPages) return null;
|
</div>
|
||||||
return (
|
<div className="flex flex-col items-end space-y-2">
|
||||||
<button
|
<span
|
||||||
key={page}
|
className={`inline-flex px-3 py-1 text-sm font-medium rounded-full ${getInvoiceStatusColor(invoice.status)}`}
|
||||||
onClick={() => setCurrentPage(page)}
|
|
||||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
|
||||||
page === currentPage
|
|
||||||
? "z-10 bg-blue-50 border-blue-500 text-blue-600"
|
|
||||||
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{page}
|
{invoice.status}
|
||||||
</button>
|
</span>
|
||||||
);
|
<span className="text-lg font-bold text-gray-900">
|
||||||
})}
|
{formatCurrency(invoice.total)}
|
||||||
<button
|
</span>
|
||||||
onClick={() =>
|
</div>
|
||||||
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
|
</div>
|
||||||
}
|
<div className="mt-4 flex items-center justify-between">
|
||||||
disabled={currentPage === pagination.totalPages}
|
<div className="text-sm text-gray-500">
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
<span className="block">
|
||||||
>
|
Due:{" "}
|
||||||
Next
|
{invoice.dueDate
|
||||||
</button>
|
? format(new Date(invoice.dueDate), "MMM d, yyyy")
|
||||||
</nav>
|
: "N/A"}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
(window.location.href = `/billing/invoices/${invoice.id}`)
|
||||||
|
}
|
||||||
|
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||||
|
>
|
||||||
|
<DocumentTextIcon className="h-4 w-4 mr-2" />
|
||||||
|
View Invoice
|
||||||
|
<ArrowTopRightOnSquareIcon className="h-4 w-4 ml-2" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
{/* Pagination */}
|
||||||
)}
|
{pagination && pagination.totalPages > 1 && (
|
||||||
</div>
|
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||||
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === pagination.totalPages}
|
||||||
|
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{(currentPage - 1) * itemsPerPage + 1}
|
||||||
|
</span>{" "}
|
||||||
|
to{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{Math.min(currentPage * itemsPerPage, pagination.totalItems)}
|
||||||
|
</span>{" "}
|
||||||
|
of <span className="font-medium">{pagination.totalItems}</span> results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: Math.min(pagination.totalPages, 5) }, (_, i) => {
|
||||||
|
const startPage = Math.max(1, currentPage - 2);
|
||||||
|
const page = startPage + i;
|
||||||
|
if (page > pagination.totalPages) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={page}
|
||||||
|
onClick={() => setCurrentPage(page)}
|
||||||
|
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||||
|
page === currentPage
|
||||||
|
? "z-10 bg-blue-50 border-blue-500 text-blue-600"
|
||||||
|
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === pagination.totalPages}
|
||||||
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,8 +41,8 @@ export default function SimCancelPage() {
|
|||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
|
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
Cancel SIM: Permanently cancel your SIM service. This action cannot be
|
Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will
|
||||||
undone and will terminate your service immediately.
|
terminate your service immediately.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
|||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
|
|
||||||
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
|
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
|
||||||
type PlanCode = typeof PLAN_CODES[number];
|
type PlanCode = (typeof PLAN_CODES)[number];
|
||||||
const PLAN_LABELS: Record<PlanCode, string> = {
|
const PLAN_LABELS: Record<PlanCode, string> = {
|
||||||
PASI_5G: "5GB",
|
PASI_5G: "5GB",
|
||||||
PASI_10G: "10GB",
|
PASI_10G: "10GB",
|
||||||
@ -24,7 +24,10 @@ export default function SimChangePlanPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const options = useMemo(() => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)), [currentPlanCode]);
|
const options = useMemo(
|
||||||
|
() => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)),
|
||||||
|
[currentPlanCode]
|
||||||
|
);
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -51,32 +54,62 @@ export default function SimChangePlanPage() {
|
|||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="max-w-3xl mx-auto p-6">
|
<div className="max-w-3xl mx-auto p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700">← Back to SIM Management</Link>
|
<Link
|
||||||
|
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||||
|
className="text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
← Back to SIM Management
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-900 mb-1">Change Plan</h1>
|
<h1 className="text-xl font-semibold text-gray-900 mb-1">Change Plan</h1>
|
||||||
<p className="text-sm text-gray-600 mb-6">Change Plan: Switch to a different data plan. Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month.</p>
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
{message && <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div>}
|
Change Plan: Switch to a different data plan. Important: Plan changes must be requested
|
||||||
{error && <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>}
|
before the 25th of the month. Changes will take effect on the 1st of the following
|
||||||
|
month.
|
||||||
|
</p>
|
||||||
|
{message && (
|
||||||
|
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={submit} className="space-y-6">
|
<form onSubmit={submit} 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
|
||||||
value={newPlanCode}
|
value={newPlanCode}
|
||||||
onChange={(e) => setNewPlanCode(e.target.value as PlanCode)}
|
onChange={e => setNewPlanCode(e.target.value as PlanCode)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||||
>
|
>
|
||||||
<option value="">Choose a plan</option>
|
<option value="">Choose a plan</option>
|
||||||
{options.map(code => (
|
{options.map(code => (
|
||||||
<option key={code} value={code}>{PLAN_LABELS[code]}</option>
|
<option key={code} value={code}>
|
||||||
|
{PLAN_LABELS[code]}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button type="submit" disabled={loading} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">{loading ? 'Processing…' : 'Submit Plan Change'}</button>
|
<button
|
||||||
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Back</Link>
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Processing…" : "Submit Plan Change"}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||||
|
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
export default function Page(){return null}
|
export default function Page() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { authenticatedApi } from "@/lib/api";
|
|||||||
export default function SimTopUpPage() {
|
export default function SimTopUpPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const subscriptionId = parseInt(params.id as string);
|
const subscriptionId = parseInt(params.id as string);
|
||||||
const [gbAmount, setGbAmount] = useState<string>('1');
|
const [gbAmount, setGbAmount] = useState<string>("1");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -31,23 +31,23 @@ export default function SimTopUpPage() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!isValidAmount()) {
|
if (!isValidAmount()) {
|
||||||
setError('Please enter a whole number between 1 GB and 100 GB');
|
setError("Please enter a whole number between 1 GB and 100 GB");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, {
|
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, {
|
||||||
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: any) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
@ -57,19 +57,26 @@ export default function SimTopUpPage() {
|
|||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="max-w-3xl mx-auto p-6">
|
<div className="max-w-3xl mx-auto p-6">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700">← Back to SIM Management</Link>
|
<Link
|
||||||
|
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||||
|
className="text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
← Back to SIM Management
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<h1 className="text-xl font-semibold text-gray-900 mb-1">Top Up Data</h1>
|
<h1 className="text-xl font-semibold text-gray-900 mb-1">Top Up Data</h1>
|
||||||
<p className="text-sm text-gray-600 mb-6">Add additional data quota to your SIM service. Enter the amount of data you want to add.</p>
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
Add additional data quota to your SIM service. Enter the amount of data you want to add.
|
||||||
|
</p>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
|
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
|
||||||
{error}
|
{error}
|
||||||
@ -79,17 +86,15 @@ export default function SimTopUpPage() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Amount Input */}
|
{/* Amount Input */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
||||||
Amount (GB)
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={gbAmount}
|
value={gbAmount}
|
||||||
onChange={(e) => setGbAmount(e.target.value)}
|
onChange={e => setGbAmount(e.target.value)}
|
||||||
placeholder="Enter amount in GB"
|
placeholder="Enter amount in GB"
|
||||||
min="1"
|
min="1"
|
||||||
max="50"
|
max="50"
|
||||||
step="1"
|
step="1"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 pr-12"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 pr-12"
|
||||||
/>
|
/>
|
||||||
@ -97,9 +102,9 @@ export default function SimTopUpPage() {
|
|||||||
<span className="text-gray-500 text-sm">GB</span>
|
<span className="text-gray-500 text-sm">GB</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
|
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cost Display */}
|
{/* Cost Display */}
|
||||||
@ -107,19 +112,15 @@ export default function SimTopUpPage() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-blue-900">
|
<div className="text-sm font-medium text-blue-900">
|
||||||
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'}
|
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
|
||||||
</div>
|
|
||||||
<div className="text-xs text-blue-700">
|
|
||||||
= {getCurrentAmountMb()} MB
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-blue-700">= {getCurrentAmountMb()} MB</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-lg font-bold text-blue-900">
|
<div className="text-lg font-bold text-blue-900">
|
||||||
¥{calculateCost().toLocaleString()}
|
¥{calculateCost().toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-blue-700">
|
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
||||||
(1GB = ¥500)
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -128,12 +129,20 @@ export default function SimTopUpPage() {
|
|||||||
{!isValidAmount() && gbAmount && (
|
{!isValidAmount() && gbAmount && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="h-4 w-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
<svg
|
||||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
className="h-4 w-4 text-red-500 mr-2"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-sm text-red-800">
|
<p className="text-sm text-red-800">
|
||||||
Amount must be a whole number between 1 GB and 50 GB
|
Amount must be a whole number between 1 GB and 50 GB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -145,7 +154,7 @@ export default function SimTopUpPage() {
|
|||||||
disabled={loading || !isValidAmount()}
|
disabled={loading || !isValidAmount()}
|
||||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
{loading ? 'Processing...' : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||||
|
|||||||
@ -129,10 +129,10 @@ export default function SubscriptionsPage() {
|
|||||||
key: "cycle",
|
key: "cycle",
|
||||||
header: "Billing Cycle",
|
header: "Billing Cycle",
|
||||||
render: (subscription: Subscription) => {
|
render: (subscription: Subscription) => {
|
||||||
const name = (subscription.productName || '').toLowerCase();
|
const name = (subscription.productName || "").toLowerCase();
|
||||||
const looksLikeActivation =
|
const looksLikeActivation =
|
||||||
name.includes('activation fee') || name.includes('activation') || name.includes('setup');
|
name.includes("activation fee") || name.includes("activation") || name.includes("setup");
|
||||||
const displayCycle = looksLikeActivation ? 'One-time' : subscription.cycle;
|
const displayCycle = looksLikeActivation ? "One-time" : subscription.cycle;
|
||||||
return <span className="text-sm text-gray-900">{displayCycle}</span>;
|
return <span className="text-sm text-gray-900">{displayCycle}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -343,7 +343,9 @@ function NavigationItem({
|
|||||||
|
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
const isActive = hasChildren
|
const isActive = hasChildren
|
||||||
? item.children?.some((child: NavigationChild) => pathname.startsWith((child.href || "").split(/[?#]/)[0])) || false
|
? item.children?.some((child: NavigationChild) =>
|
||||||
|
pathname.startsWith((child.href || "").split(/[?#]/)[0])
|
||||||
|
) || false
|
||||||
: item.href
|
: item.href
|
||||||
? pathname === item.href
|
? pathname === item.href
|
||||||
: false;
|
: false;
|
||||||
|
|||||||
@ -22,10 +22,7 @@ export function ServiceManagementSection({
|
|||||||
subscriptionId,
|
subscriptionId,
|
||||||
productName,
|
productName,
|
||||||
}: ServiceManagementSectionProps) {
|
}: ServiceManagementSectionProps) {
|
||||||
const isSimService = useMemo(
|
const isSimService = useMemo(() => productName?.toLowerCase().includes("sim"), [productName]);
|
||||||
() => productName?.toLowerCase().includes("sim"),
|
|
||||||
[productName]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedService, setSelectedService] = useState<ServiceKey>(
|
const [selectedService, setSelectedService] = useState<ServiceKey>(
|
||||||
isSimService ? "SIM" : "INTERNET"
|
isSimService ? "SIM" : "INTERNET"
|
||||||
@ -59,7 +56,7 @@ export function ServiceManagementSection({
|
|||||||
id="service-selector"
|
id="service-selector"
|
||||||
className="block w-48 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
className="block w-48 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||||
value={selectedService}
|
value={selectedService}
|
||||||
onChange={(e) => setSelectedService(e.target.value as ServiceKey)}
|
onChange={e => setSelectedService(e.target.value as ServiceKey)}
|
||||||
>
|
>
|
||||||
<option value="SIM">SIM</option>
|
<option value="SIM">SIM</option>
|
||||||
<option value="INTERNET">Internet (coming soon)</option>
|
<option value="INTERNET">Internet (coming soon)</option>
|
||||||
@ -99,12 +96,8 @@ export function ServiceManagementSection({
|
|||||||
) : (
|
) : (
|
||||||
<div className="bg-white shadow rounded-lg p-6 text-center">
|
<div className="bg-white shadow rounded-lg p-6 text-center">
|
||||||
<DevicePhoneMobileIcon className="mx-auto h-12 w-12 text-gray-400" />
|
<DevicePhoneMobileIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<h4 className="mt-2 text-sm font-medium text-gray-900">
|
<h4 className="mt-2 text-sm font-medium text-gray-900">SIM management not available</h4>
|
||||||
SIM management not available
|
<p className="mt-1 text-sm text-gray-500">This subscription is not a SIM service.</p>
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
This subscription is not a SIM service.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : selectedService === "INTERNET" ? (
|
) : selectedService === "INTERNET" ? (
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { ServiceManagementSection } from './components/ServiceManagementSection';
|
export { ServiceManagementSection } from "./components/ServiceManagementSection";
|
||||||
|
|||||||
@ -12,9 +12,15 @@ interface ChangePlanModalProps {
|
|||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSuccess, onError }: ChangePlanModalProps) {
|
export function ChangePlanModal({
|
||||||
|
subscriptionId,
|
||||||
|
currentPlanCode,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: ChangePlanModalProps) {
|
||||||
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
|
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
|
||||||
type PlanCode = typeof PLAN_CODES[number];
|
type PlanCode = (typeof PLAN_CODES)[number];
|
||||||
const PLAN_LABELS: Record<PlanCode, string> = {
|
const PLAN_LABELS: Record<PlanCode, string> = {
|
||||||
PASI_5G: "5GB",
|
PASI_5G: "5GB",
|
||||||
PASI_10G: "10GB",
|
PASI_10G: "10GB",
|
||||||
@ -22,7 +28,9 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
|
|||||||
PASI_50G: "50GB",
|
PASI_50G: "50GB",
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(code => code !== (currentPlanCode || ''));
|
const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(
|
||||||
|
code => code !== (currentPlanCode || "")
|
||||||
|
);
|
||||||
|
|
||||||
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
|
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -48,9 +56,14 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
|
||||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="sm:flex sm:items-start">
|
<div className="sm:flex sm:items-start">
|
||||||
@ -63,18 +76,25 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Select New Plan</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Select New Plan
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={newPlanCode}
|
value={newPlanCode}
|
||||||
onChange={(e) => setNewPlanCode(e.target.value as PlanCode)}
|
onChange={e => setNewPlanCode(e.target.value as PlanCode)}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Choose a plan</option>
|
<option value="">Choose a plan</option>
|
||||||
{allowedPlans.map(code => (
|
{allowedPlans.map(code => (
|
||||||
<option key={code} value={code}>{PLAN_LABELS[code]}</option>
|
<option key={code} value={code}>
|
||||||
|
{PLAN_LABELS[code]}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-gray-500">Only plans different from your current plan are listed. The change will be scheduled for the 1st of the next month.</p>
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Only plans different from your current plan are listed. The change will be
|
||||||
|
scheduled for the 1st of the next month.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import {
|
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
ChartBarIcon,
|
|
||||||
ExclamationTriangleIcon
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
export interface SimUsage {
|
export interface SimUsage {
|
||||||
account: string;
|
account: string;
|
||||||
@ -26,7 +23,13 @@ interface DataUsageChartProps {
|
|||||||
embedded?: boolean; // when true, render content without card container
|
embedded?: boolean; // when true, render content without card container
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embedded = false }: DataUsageChartProps) {
|
export function DataUsageChart({
|
||||||
|
usage,
|
||||||
|
remainingQuotaMb,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
embedded = false,
|
||||||
|
}: DataUsageChartProps) {
|
||||||
const formatUsage = (usageMb: number) => {
|
const formatUsage = (usageMb: number) => {
|
||||||
if (usageMb >= 1000) {
|
if (usageMb >= 1000) {
|
||||||
return `${(usageMb / 1000).toFixed(1)} GB`;
|
return `${(usageMb / 1000).toFixed(1)} GB`;
|
||||||
@ -35,22 +38,22 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getUsageColor = (percentage: number) => {
|
const getUsageColor = (percentage: number) => {
|
||||||
if (percentage >= 90) return 'bg-red-500';
|
if (percentage >= 90) return "bg-red-500";
|
||||||
if (percentage >= 75) return 'bg-yellow-500';
|
if (percentage >= 75) return "bg-yellow-500";
|
||||||
if (percentage >= 50) return 'bg-orange-500';
|
if (percentage >= 50) return "bg-orange-500";
|
||||||
return 'bg-green-500';
|
return "bg-green-500";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUsageTextColor = (percentage: number) => {
|
const getUsageTextColor = (percentage: number) => {
|
||||||
if (percentage >= 90) return 'text-red-600';
|
if (percentage >= 90) return "text-red-600";
|
||||||
if (percentage >= 75) return 'text-yellow-600';
|
if (percentage >= 75) return "text-yellow-600";
|
||||||
if (percentage >= 50) return 'text-orange-600';
|
if (percentage >= 50) return "text-orange-600";
|
||||||
return 'text-green-600';
|
return "text-green-600";
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg '}p-6`}>
|
<div className={`${embedded ? "" : "bg-white shadow rounded-lg "}p-6`}>
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||||
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||||
@ -66,7 +69,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg '}p-6`}>
|
<div className={`${embedded ? "" : "bg-white shadow rounded-lg "}p-6`}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Usage Data</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Usage Data</h3>
|
||||||
@ -77,14 +80,17 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total usage from recent days (assume it includes today)
|
// Calculate total usage from recent days (assume it includes today)
|
||||||
const totalRecentUsage = usage.recentDaysUsage.reduce((sum, day) => sum + day.usageMb, 0) + usage.todayUsageMb;
|
const totalRecentUsage =
|
||||||
|
usage.recentDaysUsage.reduce((sum, day) => sum + day.usageMb, 0) + usage.todayUsageMb;
|
||||||
const totalQuota = remainingQuotaMb + totalRecentUsage;
|
const totalQuota = remainingQuotaMb + totalRecentUsage;
|
||||||
const usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0;
|
const usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300'}`}>
|
<div
|
||||||
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-5 border-b border-gray-200'}`}>
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
||||||
<ChartBarIcon className="h-6 w-6 text-blue-600" />
|
<ChartBarIcon className="h-6 w-6 text-blue-600" />
|
||||||
@ -97,7 +103,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||||
{/* Current Usage Overview */}
|
{/* Current Usage Overview */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
@ -106,15 +112,15 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
{formatUsage(totalRecentUsage)} of {formatUsage(totalQuota)}
|
{formatUsage(totalRecentUsage)} of {formatUsage(totalQuota)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
className={`h-3 rounded-full transition-all duration-300 ${getUsageColor(usagePercentage)}`}
|
className={`h-3 rounded-full transition-all duration-300 ${getUsageColor(usagePercentage)}`}
|
||||||
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||||
<span>0%</span>
|
<span>0%</span>
|
||||||
<span className={getUsageTextColor(usagePercentage)}>
|
<span className={getUsageTextColor(usagePercentage)}>
|
||||||
@ -135,13 +141,23 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
<div className="text-sm font-medium text-blue-700 mt-1">Used today</div>
|
<div className="text-sm font-medium text-blue-700 mt-1">Used today</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-blue-200 rounded-full p-3">
|
<div className="bg-blue-200 rounded-full p-3">
|
||||||
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
className="h-6 w-6 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-6 border border-green-200">
|
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-6 border border-green-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@ -151,8 +167,18 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
<div className="text-sm font-medium text-green-700 mt-1">Remaining</div>
|
<div className="text-sm font-medium text-green-700 mt-1">Remaining</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-200 rounded-full p-3">
|
<div className="bg-green-200 rounded-full p-3">
|
||||||
<svg className="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4m16 0l-4 4m4-4l-4-4" />
|
className="h-6 w-6 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M20 12H4m16 0l-4 4m4-4l-4-4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -171,14 +197,14 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center justify-between py-2">
|
<div key={index} className="flex items-center justify-between py-2">
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
{new Date(day.date).toLocaleDateString('en-US', {
|
{new Date(day.date).toLocaleDateString("en-US", {
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-24 bg-gray-200 rounded-full h-2">
|
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${Math.min(dayPercentage, 100)}%` }}
|
style={{ width: `${Math.min(dayPercentage, 100)}%` }}
|
||||||
></div>
|
></div>
|
||||||
@ -216,7 +242,8 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
<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 to avoid service interruption.
|
You've used {usagePercentage.toFixed(1)}% of your data quota. Consider topping up
|
||||||
|
to avoid service interruption.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -230,7 +257,8 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
|||||||
<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 your usage.
|
You've used {usagePercentage.toFixed(1)}% of your data quota. Consider monitoring
|
||||||
|
your usage.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
CheckCircleIcon
|
CheckCircleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from "@heroicons/react/24/outline";
|
||||||
import { TopUpModal } from './TopUpModal';
|
import { TopUpModal } from "./TopUpModal";
|
||||||
import { ChangePlanModal } from './ChangePlanModal';
|
import { ChangePlanModal } from "./ChangePlanModal";
|
||||||
import { authenticatedApi } from '@/lib/api';
|
import { authenticatedApi } from "@/lib/api";
|
||||||
|
|
||||||
interface SimActionsProps {
|
interface SimActionsProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
simType: 'physical' | 'esim';
|
simType: "physical" | "esim";
|
||||||
status: string;
|
status: string;
|
||||||
onTopUpSuccess?: () => void;
|
onTopUpSuccess?: () => void;
|
||||||
onPlanChangeSuccess?: () => void;
|
onPlanChangeSuccess?: () => void;
|
||||||
@ -25,16 +25,16 @@ interface SimActionsProps {
|
|||||||
currentPlanCode?: string;
|
currentPlanCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SimActions({
|
export function SimActions({
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
simType,
|
simType,
|
||||||
status,
|
status,
|
||||||
onTopUpSuccess,
|
onTopUpSuccess,
|
||||||
onPlanChangeSuccess,
|
onPlanChangeSuccess,
|
||||||
onCancelSuccess,
|
onCancelSuccess,
|
||||||
onReissueSuccess,
|
onReissueSuccess,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
currentPlanCode
|
currentPlanCode,
|
||||||
}: SimActionsProps) {
|
}: SimActionsProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||||
@ -45,43 +45,43 @@ export function SimActions({
|
|||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const [showChangePlanModal, setShowChangePlanModal] = useState(false);
|
const [showChangePlanModal, setShowChangePlanModal] = useState(false);
|
||||||
const [activeInfo, setActiveInfo] = useState<
|
const [activeInfo, setActiveInfo] = useState<
|
||||||
'topup' | 'reissue' | 'cancel' | 'changePlan' | null
|
"topup" | "reissue" | "cancel" | "changePlan" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const isActive = status === 'active';
|
const isActive = status === "active";
|
||||||
const canTopUp = isActive;
|
const canTopUp = isActive;
|
||||||
const canReissue = isActive && simType === 'esim';
|
const canReissue = isActive && simType === "esim";
|
||||||
const canCancel = isActive;
|
const canCancel = isActive;
|
||||||
|
|
||||||
const handleReissueEsim = async () => {
|
const handleReissueEsim = async () => {
|
||||||
setLoading('reissue');
|
setLoading("reissue");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);
|
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);
|
||||||
|
|
||||||
setSuccess('eSIM profile reissued successfully');
|
setSuccess("eSIM profile reissued successfully");
|
||||||
setShowReissueConfirm(false);
|
setShowReissueConfirm(false);
|
||||||
onReissueSuccess?.();
|
onReissueSuccess?.();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelSim = async () => {
|
const handleCancelSim = async () => {
|
||||||
setLoading('cancel');
|
setLoading("cancel");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
||||||
|
|
||||||
setSuccess('SIM service cancelled successfully');
|
setSuccess("SIM service cancelled successfully");
|
||||||
setShowCancelConfirm(false);
|
setShowCancelConfirm(false);
|
||||||
onCancelSuccess?.();
|
onCancelSuccess?.();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
@ -100,13 +100,26 @@ export function SimActions({
|
|||||||
}, [success, error]);
|
}, [success, error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="sim-actions" className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300'}`}>
|
<div
|
||||||
|
id="sim-actions"
|
||||||
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-5 border-b border-gray-200'}`}>
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
||||||
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
className="h-6 w-6 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -117,7 +130,7 @@ export function SimActions({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||||
{/* Status Messages */}
|
{/* Status Messages */}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
@ -149,11 +162,11 @@ export function SimActions({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className={`grid gap-4 ${embedded ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
<div className={`grid gap-4 ${embedded ? "grid-cols-1" : "grid-cols-2"}`}>
|
||||||
{/* Top Up Data - Primary Action */}
|
{/* Top Up Data - Primary Action */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveInfo('topup');
|
setActiveInfo("topup");
|
||||||
try {
|
try {
|
||||||
router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
||||||
} catch {
|
} catch {
|
||||||
@ -163,23 +176,23 @@ export function SimActions({
|
|||||||
disabled={!canTopUp || loading !== null}
|
disabled={!canTopUp || loading !== null}
|
||||||
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||||
canTopUp && loading === null
|
canTopUp && loading === null
|
||||||
? 'text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
|
? "text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
: 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
|
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="bg-blue-100 rounded-lg p-1 mr-3">
|
<div className="bg-blue-100 rounded-lg p-1 mr-3">
|
||||||
<PlusIcon className="h-5 w-5 text-blue-600" />
|
<PlusIcon className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<span>{loading === 'topup' ? 'Processing...' : 'Top Up Data'}</span>
|
<span>{loading === "topup" ? "Processing..." : "Top Up Data"}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Reissue eSIM (only for eSIMs) */}
|
{/* Reissue eSIM (only for eSIMs) */}
|
||||||
{simType === 'esim' && (
|
{simType === "esim" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveInfo('reissue');
|
setActiveInfo("reissue");
|
||||||
try {
|
try {
|
||||||
router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
||||||
} catch {
|
} catch {
|
||||||
@ -189,15 +202,15 @@ export function SimActions({
|
|||||||
disabled={!canReissue || loading !== null}
|
disabled={!canReissue || loading !== null}
|
||||||
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||||
canReissue && loading === null
|
canReissue && loading === null
|
||||||
? 'text-green-700 bg-green-50 border-green-200 hover:bg-green-100 hover:border-green-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'
|
? "text-green-700 bg-green-50 border-green-200 hover:bg-green-100 hover:border-green-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||||
: 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
|
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="bg-green-100 rounded-lg p-1 mr-3">
|
<div className="bg-green-100 rounded-lg p-1 mr-3">
|
||||||
<ArrowPathIcon className="h-5 w-5 text-green-600" />
|
<ArrowPathIcon className="h-5 w-5 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<span>{loading === 'reissue' ? 'Processing...' : 'Reissue eSIM'}</span>
|
<span>{loading === "reissue" ? "Processing..." : "Reissue eSIM"}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -205,7 +218,7 @@ export function SimActions({
|
|||||||
{/* Cancel SIM - Destructive Action */}
|
{/* Cancel SIM - Destructive Action */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveInfo('cancel');
|
setActiveInfo("cancel");
|
||||||
try {
|
try {
|
||||||
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||||
} catch {
|
} catch {
|
||||||
@ -216,22 +229,22 @@ export function SimActions({
|
|||||||
disabled={!canCancel || loading !== null}
|
disabled={!canCancel || loading !== null}
|
||||||
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||||
canCancel && loading === null
|
canCancel && loading === null
|
||||||
? 'text-red-700 bg-red-50 border-red-200 hover:bg-red-100 hover:border-red-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'
|
? "text-red-700 bg-red-50 border-red-200 hover:bg-red-100 hover:border-red-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||||
: 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
|
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="bg-red-100 rounded-lg p-1 mr-3">
|
<div className="bg-red-100 rounded-lg p-1 mr-3">
|
||||||
<XMarkIcon className="h-5 w-5 text-red-600" />
|
<XMarkIcon className="h-5 w-5 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<span>{loading === 'cancel' ? 'Processing...' : 'Cancel SIM'}</span>
|
<span>{loading === "cancel" ? "Processing..." : "Cancel SIM"}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Change Plan - Secondary Action */}
|
{/* Change Plan - Secondary Action */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveInfo('changePlan');
|
setActiveInfo("changePlan");
|
||||||
try {
|
try {
|
||||||
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||||
} catch {
|
} catch {
|
||||||
@ -241,14 +254,24 @@ export function SimActions({
|
|||||||
disabled={loading !== null}
|
disabled={loading !== null}
|
||||||
className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${
|
className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||||
loading === null
|
loading === null
|
||||||
? 'text-purple-700 bg-purple-50 border-purple-300 hover:bg-purple-100 hover:border-purple-400 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500'
|
? "text-purple-700 bg-purple-50 border-purple-300 hover:bg-purple-100 hover:border-purple-400 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
||||||
: 'text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed'
|
: "text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="bg-purple-100 rounded-lg p-1 mr-3">
|
<div className="bg-purple-100 rounded-lg p-1 mr-3">
|
||||||
<svg className="h-5 w-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
className="h-5 w-5 text-purple-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span>Change Plan</span>
|
<span>Change Plan</span>
|
||||||
@ -259,37 +282,54 @@ export function SimActions({
|
|||||||
{/* Action Description (contextual) */}
|
{/* Action Description (contextual) */}
|
||||||
{activeInfo && (
|
{activeInfo && (
|
||||||
<div className="mt-6 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
<div className="mt-6 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
{activeInfo === 'topup' && (
|
{activeInfo === "topup" && (
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<PlusIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
|
<PlusIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Top Up Data:</strong> Add additional data quota to your SIM service. You can choose the amount and schedule it for later if needed.
|
<strong>Top Up Data:</strong> Add additional data quota to your SIM service. You
|
||||||
|
can choose the amount and schedule it for later if needed.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeInfo === 'reissue' && (
|
{activeInfo === "reissue" && (
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
|
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this if your previous download failed or you need to install on a new device.
|
<strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this
|
||||||
|
if your previous download failed or you need to install on a new device.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeInfo === 'cancel' && (
|
{activeInfo === "cancel" && (
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<XMarkIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
|
<XMarkIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action cannot be undone and will terminate your service immediately.
|
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action
|
||||||
|
cannot be undone and will terminate your service immediately.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeInfo === 'changePlan' && (
|
{activeInfo === "changePlan" && (
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<svg className="h-4 w-4 text-purple-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
className="h-4 w-4 text-purple-600 mr-2 mt-0.5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<strong>Change Plan:</strong> Switch to a different data plan. <span className="text-red-600 font-medium">Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month.</span>
|
<strong>Change Plan:</strong> Switch to a different data plan.{" "}
|
||||||
|
<span className="text-red-600 font-medium">
|
||||||
|
Important: Plan changes must be requested before the 25th of the month. Changes
|
||||||
|
will take effect on the 1st of the following month.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -301,13 +341,16 @@ export function SimActions({
|
|||||||
{showTopUpModal && (
|
{showTopUpModal && (
|
||||||
<TopUpModal
|
<TopUpModal
|
||||||
subscriptionId={subscriptionId}
|
subscriptionId={subscriptionId}
|
||||||
onClose={() => { setShowTopUpModal(false); setActiveInfo(null); }}
|
onClose={() => {
|
||||||
|
setShowTopUpModal(false);
|
||||||
|
setActiveInfo(null);
|
||||||
|
}}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setShowTopUpModal(false);
|
setShowTopUpModal(false);
|
||||||
setSuccess('Data top-up completed successfully');
|
setSuccess("Data top-up completed successfully");
|
||||||
onTopUpSuccess?.();
|
onTopUpSuccess?.();
|
||||||
}}
|
}}
|
||||||
onError={(message) => setError(message)}
|
onError={message => setError(message)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -316,13 +359,16 @@ export function SimActions({
|
|||||||
<ChangePlanModal
|
<ChangePlanModal
|
||||||
subscriptionId={subscriptionId}
|
subscriptionId={subscriptionId}
|
||||||
currentPlanCode={currentPlanCode}
|
currentPlanCode={currentPlanCode}
|
||||||
onClose={() => { setShowChangePlanModal(false); setActiveInfo(null); }}
|
onClose={() => {
|
||||||
|
setShowChangePlanModal(false);
|
||||||
|
setActiveInfo(null);
|
||||||
|
}}
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
setShowChangePlanModal(false);
|
setShowChangePlanModal(false);
|
||||||
setSuccess('SIM plan change submitted successfully');
|
setSuccess("SIM plan change submitted successfully");
|
||||||
onPlanChangeSuccess?.();
|
onPlanChangeSuccess?.();
|
||||||
}}
|
}}
|
||||||
onError={(message) => setError(message)}
|
onError={message => setError(message)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -338,10 +384,13 @@ export function SimActions({
|
|||||||
<ArrowPathIcon className="h-6 w-6 text-green-600" />
|
<ArrowPathIcon className="h-6 w-6 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Reissue eSIM Profile</h3>
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
Reissue eSIM Profile
|
||||||
|
</h3>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
This will generate a new eSIM profile for download. Your current eSIM will remain active until you activate the new profile.
|
This will generate a new eSIM profile for download. Your current eSIM will
|
||||||
|
remain active until you activate the new profile.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -351,15 +400,18 @@ export function SimActions({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleReissueEsim}
|
onClick={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"
|
||||||
>
|
>
|
||||||
{loading === 'reissue' ? 'Processing...' : 'Reissue eSIM'}
|
{loading === "reissue" ? "Processing..." : "Reissue eSIM"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowReissueConfirm(false); setActiveInfo(null); }}
|
onClick={() => {
|
||||||
disabled={loading === 'reissue'}
|
setShowReissueConfirm(false);
|
||||||
|
setActiveInfo(null);
|
||||||
|
}}
|
||||||
|
disabled={loading === "reissue"}
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
@ -382,10 +434,13 @@ export function SimActions({
|
|||||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Cancel SIM Service</h3>
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
Cancel SIM Service
|
||||||
|
</h3>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Are you sure you want to cancel this SIM service? This action cannot be undone and will permanently terminate your service.
|
Are you sure you want to cancel this SIM service? This action cannot be
|
||||||
|
undone and will permanently terminate your service.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -395,15 +450,18 @@ export function SimActions({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCancelSim}
|
onClick={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"
|
||||||
>
|
>
|
||||||
{loading === 'cancel' ? 'Processing...' : 'Cancel SIM'}
|
{loading === "cancel" ? "Processing..." : "Cancel SIM"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowCancelConfirm(false); setActiveInfo(null); }}
|
onClick={() => {
|
||||||
disabled={loading === 'cancel'}
|
setShowCancelConfirm(false);
|
||||||
|
setActiveInfo(null);
|
||||||
|
}}
|
||||||
|
disabled={loading === "cancel"}
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
SignalIcon,
|
SignalIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
XCircleIcon
|
XCircleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
export interface SimDetails {
|
export interface SimDetails {
|
||||||
account: string;
|
account: string;
|
||||||
@ -18,9 +18,9 @@ export interface SimDetails {
|
|||||||
imsi?: string;
|
imsi?: string;
|
||||||
eid?: string;
|
eid?: string;
|
||||||
planCode: string;
|
planCode: string;
|
||||||
status: 'active' | 'suspended' | 'cancelled' | 'pending';
|
status: "active" | "suspended" | "cancelled" | "pending";
|
||||||
simType: 'physical' | 'esim';
|
simType: "physical" | "esim";
|
||||||
size: 'standard' | 'nano' | 'micro' | 'esim';
|
size: "standard" | "nano" | "micro" | "esim";
|
||||||
hasVoice: boolean;
|
hasVoice: boolean;
|
||||||
hasSms: boolean;
|
hasSms: boolean;
|
||||||
remainingQuotaKb: number;
|
remainingQuotaKb: number;
|
||||||
@ -46,25 +46,31 @@ interface SimDetailsCardProps {
|
|||||||
showFeaturesSummary?: boolean; // show the right-side Service Features summary
|
showFeaturesSummary?: boolean; // show the right-side Service Features summary
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SimDetailsCard({ simDetails, isLoading, error, embedded = false, showFeaturesSummary = true }: SimDetailsCardProps) {
|
export function SimDetailsCard({
|
||||||
|
simDetails,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
embedded = false,
|
||||||
|
showFeaturesSummary = true,
|
||||||
|
}: SimDetailsCardProps) {
|
||||||
const formatPlan = (code?: string) => {
|
const formatPlan = (code?: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
PASI_5G: '5GB Plan',
|
PASI_5G: "5GB Plan",
|
||||||
PASI_10G: '10GB Plan',
|
PASI_10G: "10GB Plan",
|
||||||
PASI_25G: '25GB Plan',
|
PASI_25G: "25GB Plan",
|
||||||
PASI_50G: '50GB Plan',
|
PASI_50G: "50GB Plan",
|
||||||
};
|
};
|
||||||
return (code && map[code]) || code || '—';
|
return (code && map[code]) || code || "—";
|
||||||
};
|
};
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'active':
|
case "active":
|
||||||
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
||||||
case 'suspended':
|
case "suspended":
|
||||||
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
||||||
case 'cancelled':
|
case "cancelled":
|
||||||
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
||||||
case 'pending':
|
case "pending":
|
||||||
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
||||||
default:
|
default:
|
||||||
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
|
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
|
||||||
@ -73,26 +79,26 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'active':
|
case "active":
|
||||||
return 'bg-green-100 text-green-800';
|
return "bg-green-100 text-green-800";
|
||||||
case 'suspended':
|
case "suspended":
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
return "bg-yellow-100 text-yellow-800";
|
||||||
case 'cancelled':
|
case "cancelled":
|
||||||
return 'bg-red-100 text-red-800';
|
return "bg-red-100 text-red-800";
|
||||||
case 'pending':
|
case "pending":
|
||||||
return 'bg-blue-100 text-blue-800';
|
return "bg-blue-100 text-blue-800";
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-800';
|
return "bg-gray-100 text-gray-800";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString("en-US", {
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric',
|
day: "numeric",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return dateString;
|
return dateString;
|
||||||
@ -108,7 +114,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
const Skeleton = (
|
const Skeleton = (
|
||||||
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 '}p-6 lg:p-8`}>
|
<div
|
||||||
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 "}p-6 lg:p-8`}
|
||||||
|
>
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
|
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
|
||||||
@ -130,7 +138,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-red-100 '}p-6 lg:p-8`}>
|
<div
|
||||||
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-red-100 "}p-6 lg:p-8`}
|
||||||
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
|
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
|
||||||
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
|
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
|
||||||
@ -143,11 +153,13 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Specialized, minimal eSIM details view
|
// Specialized, minimal eSIM details view
|
||||||
if (simDetails.simType === 'esim') {
|
if (simDetails.simType === "esim") {
|
||||||
return (
|
return (
|
||||||
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300'}`}>
|
<div
|
||||||
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-5 border-b border-gray-200'}`}>
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
||||||
@ -155,16 +167,20 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3>
|
<h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3>
|
||||||
<p className="text-sm text-gray-600 font-medium">Current Plan: {formatPlan(simDetails.planCode)}</p>
|
<p className="text-sm text-gray-600 font-medium">
|
||||||
|
Current Plan: {formatPlan(simDetails.planCode)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}>
|
<span
|
||||||
|
className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}
|
||||||
|
>
|
||||||
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@ -174,15 +190,21 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Phone Number</label>
|
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
Phone Number
|
||||||
|
</label>
|
||||||
<p className="text-lg font-semibold text-gray-900 mt-1">{simDetails.msisdn}</p>
|
<p className="text-lg font-semibold text-gray-900 mt-1">{simDetails.msisdn}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Data Remaining</label>
|
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
<p className="text-2xl font-bold text-green-600 mt-1">{formatQuota(simDetails.remainingQuotaMb)}</p>
|
Data Remaining
|
||||||
|
</label>
|
||||||
|
<p className="text-2xl font-bold text-green-600 mt-1">
|
||||||
|
{formatQuota(simDetails.remainingQuotaMb)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -195,26 +217,32 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
|
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
|
||||||
<span className="text-sm text-gray-700">Voice Mail (¥300/month)</span>
|
<span className="text-sm text-gray-700">Voice Mail (¥300/month)</span>
|
||||||
<span className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.voiceMailEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
|
<span
|
||||||
{simDetails.voiceMailEnabled ? 'Enabled' : 'Disabled'}
|
className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.voiceMailEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
|
||||||
|
>
|
||||||
|
{simDetails.voiceMailEnabled ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
|
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
|
||||||
<span className="text-sm text-gray-700">Call Waiting (¥300/month)</span>
|
<span className="text-sm text-gray-700">Call Waiting (¥300/month)</span>
|
||||||
<span className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.callWaitingEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
|
<span
|
||||||
{simDetails.callWaitingEnabled ? 'Enabled' : 'Disabled'}
|
className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.callWaitingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
|
||||||
|
>
|
||||||
|
{simDetails.callWaitingEnabled ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
|
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
|
||||||
<span className="text-sm text-gray-700">International Roaming</span>
|
<span className="text-sm text-gray-700">International Roaming</span>
|
||||||
<span className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.internationalRoamingEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
|
<span
|
||||||
{simDetails.internationalRoamingEnabled ? 'Enabled' : 'Disabled'}
|
className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.internationalRoamingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
|
||||||
|
>
|
||||||
|
{simDetails.internationalRoamingEnabled ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center py-2 px-3 bg-blue-50 rounded-lg">
|
<div className="flex justify-between items-center py-2 px-3 bg-blue-50 rounded-lg">
|
||||||
<span className="text-sm text-gray-700">4G/5G</span>
|
<span className="text-sm text-gray-700">4G/5G</span>
|
||||||
<span className="text-sm font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800">
|
<span className="text-sm font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800">
|
||||||
{simDetails.networkType || '5G'}
|
{simDetails.networkType || "5G"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -227,9 +255,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg'}`}>
|
<div className={`${embedded ? "" : "bg-white shadow rounded-lg"}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${embedded ? '' : 'px-6 py-4 border-b border-gray-200'}`}>
|
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="text-2xl mr-3">
|
<div className="text-2xl mr-3">
|
||||||
@ -244,7 +272,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{getStatusIcon(simDetails.status)}
|
{getStatusIcon(simDetails.status)}
|
||||||
<span className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}>
|
<span
|
||||||
|
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
|
||||||
|
>
|
||||||
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -252,7 +282,7 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={`${embedded ? '' : 'px-6 py-4'}`}>
|
<div className={`${embedded ? "" : "px-6 py-4"}`}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* SIM Information */}
|
{/* SIM Information */}
|
||||||
<div>
|
<div>
|
||||||
@ -264,8 +294,8 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
<label className="text-xs text-gray-500">Phone Number</label>
|
<label className="text-xs text-gray-500">Phone Number</label>
|
||||||
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
|
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{simDetails.simType === 'physical' && (
|
{simDetails.simType === "physical" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">ICCID</label>
|
<label className="text-xs text-gray-500">ICCID</label>
|
||||||
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
|
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
|
||||||
@ -304,20 +334,30 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">Data Remaining</label>
|
<label className="text-xs text-gray-500">Data Remaining</label>
|
||||||
<p className="text-lg font-semibold text-green-600">{formatQuota(simDetails.remainingQuotaMb)}</p>
|
<p className="text-lg font-semibold text-green-600">
|
||||||
|
{formatQuota(simDetails.remainingQuotaMb)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<SignalIcon className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? 'text-green-500' : 'text-gray-400'}`} />
|
<SignalIcon
|
||||||
<span className={`text-sm ${simDetails.hasVoice ? 'text-green-600' : 'text-gray-500'}`}>
|
className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? "text-green-500" : "text-gray-400"}`}
|
||||||
Voice {simDetails.hasVoice ? 'Enabled' : 'Disabled'}
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${simDetails.hasVoice ? "text-green-600" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
Voice {simDetails.hasVoice ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<DevicePhoneMobileIcon className={`h-4 w-4 mr-1 ${simDetails.hasSms ? 'text-green-500' : 'text-gray-400'}`} />
|
<DevicePhoneMobileIcon
|
||||||
<span className={`text-sm ${simDetails.hasSms ? 'text-green-600' : 'text-gray-500'}`}>
|
className={`h-4 w-4 mr-1 ${simDetails.hasSms ? "text-green-500" : "text-gray-400"}`}
|
||||||
SMS {simDetails.hasSms ? 'Enabled' : 'Disabled'}
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${simDetails.hasSms ? "text-green-600" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
SMS {simDetails.hasSms ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -23,18 +23,21 @@ export function SimFeatureToggles({
|
|||||||
embedded = false,
|
embedded = false,
|
||||||
}: SimFeatureTogglesProps) {
|
}: SimFeatureTogglesProps) {
|
||||||
// Initial values
|
// Initial values
|
||||||
const initial = useMemo(() => ({
|
const initial = useMemo(
|
||||||
vm: !!voiceMailEnabled,
|
() => ({
|
||||||
cw: !!callWaitingEnabled,
|
vm: !!voiceMailEnabled,
|
||||||
ir: !!internationalRoamingEnabled,
|
cw: !!callWaitingEnabled,
|
||||||
nt: networkType === '5G' ? '5G' : '4G',
|
ir: !!internationalRoamingEnabled,
|
||||||
}), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]);
|
nt: networkType === "5G" ? "5G" : "4G",
|
||||||
|
}),
|
||||||
|
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
|
||||||
|
);
|
||||||
|
|
||||||
// Working values
|
// Working values
|
||||||
const [vm, setVm] = useState(initial.vm);
|
const [vm, setVm] = useState(initial.vm);
|
||||||
const [cw, setCw] = useState(initial.cw);
|
const [cw, setCw] = useState(initial.cw);
|
||||||
const [ir, setIr] = useState(initial.ir);
|
const [ir, setIr] = useState(initial.ir);
|
||||||
const [nt, setNt] = useState<'4G' | '5G'>(initial.nt as '4G' | '5G');
|
const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
@ -43,14 +46,14 @@ export function SimFeatureToggles({
|
|||||||
setVm(initial.vm);
|
setVm(initial.vm);
|
||||||
setCw(initial.cw);
|
setCw(initial.cw);
|
||||||
setIr(initial.ir);
|
setIr(initial.ir);
|
||||||
setNt(initial.nt as '4G' | '5G');
|
setNt(initial.nt as "4G" | "5G");
|
||||||
}, [initial.vm, initial.cw, initial.ir, initial.nt]);
|
}, [initial.vm, initial.cw, initial.ir, initial.nt]);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setVm(initial.vm);
|
setVm(initial.vm);
|
||||||
setCw(initial.cw);
|
setCw(initial.cw);
|
||||||
setIr(initial.ir);
|
setIr(initial.ir);
|
||||||
setNt(initial.nt as '4G' | '5G');
|
setNt(initial.nt as "4G" | "5G");
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
};
|
};
|
||||||
@ -67,13 +70,16 @@ export function SimFeatureToggles({
|
|||||||
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(`/subscriptions/${subscriptionId}/sim/features`, featurePayload);
|
await authenticatedApi.post(
|
||||||
|
`/subscriptions/${subscriptionId}/sim/features`,
|
||||||
|
featurePayload
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess('Changes submitted successfully');
|
setSuccess("Changes submitted successfully");
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
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);
|
||||||
setTimeout(() => setSuccess(null), 3000);
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
@ -82,18 +88,28 @@ export function SimFeatureToggles({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* Service Options */}
|
{/* Service Options */}
|
||||||
<div className={`${embedded ? '' : 'bg-white rounded-xl border border-gray-200 overflow-hidden'}`}>
|
<div
|
||||||
|
className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 overflow-hidden"}`}
|
||||||
<div className={`${embedded ? '' : 'p-6'} space-y-6`}>
|
>
|
||||||
|
<div className={`${embedded ? "" : "p-6"} space-y-6`}>
|
||||||
{/* Voice Mail */}
|
{/* Voice Mail */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-blue-100 rounded-lg p-2">
|
<div className="bg-blue-100 rounded-lg p-2">
|
||||||
<svg className="h-4 w-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
className="h-4 w-4 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -105,14 +121,14 @@ export function SimFeatureToggles({
|
|||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-gray-500">Current: </span>
|
<span className="text-gray-500">Current: </span>
|
||||||
<span className={`font-medium ${initial.vm ? 'text-green-600' : 'text-gray-600'}`}>
|
<span className={`font-medium ${initial.vm ? "text-green-600" : "text-gray-600"}`}>
|
||||||
{initial.vm ? 'Enabled' : 'Disabled'}
|
{initial.vm ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-400">→</div>
|
<div className="text-gray-400">→</div>
|
||||||
<select
|
<select
|
||||||
value={vm ? 'Enabled' : 'Disabled'}
|
value={vm ? "Enabled" : "Disabled"}
|
||||||
onChange={(e) => setVm(e.target.value === 'Enabled')}
|
onChange={e => setVm(e.target.value === "Enabled")}
|
||||||
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
|
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
|
||||||
>
|
>
|
||||||
<option>Disabled</option>
|
<option>Disabled</option>
|
||||||
@ -126,8 +142,18 @@ export function SimFeatureToggles({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-purple-100 rounded-lg p-2">
|
<div className="bg-purple-100 rounded-lg p-2">
|
||||||
<svg className="h-4 w-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-4 w-4 text-purple-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -139,14 +165,14 @@ export function SimFeatureToggles({
|
|||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-gray-500">Current: </span>
|
<span className="text-gray-500">Current: </span>
|
||||||
<span className={`font-medium ${initial.cw ? 'text-green-600' : 'text-gray-600'}`}>
|
<span className={`font-medium ${initial.cw ? "text-green-600" : "text-gray-600"}`}>
|
||||||
{initial.cw ? 'Enabled' : 'Disabled'}
|
{initial.cw ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-400">→</div>
|
<div className="text-gray-400">→</div>
|
||||||
<select
|
<select
|
||||||
value={cw ? 'Enabled' : 'Disabled'}
|
value={cw ? "Enabled" : "Disabled"}
|
||||||
onChange={(e) => setCw(e.target.value === 'Enabled')}
|
onChange={e => setCw(e.target.value === "Enabled")}
|
||||||
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
|
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
|
||||||
>
|
>
|
||||||
<option>Disabled</option>
|
<option>Disabled</option>
|
||||||
@ -160,8 +186,18 @@ export function SimFeatureToggles({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-green-100 rounded-lg p-2">
|
<div className="bg-green-100 rounded-lg p-2">
|
||||||
<svg className="h-4 w-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-4 w-4 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -173,14 +209,14 @@ export function SimFeatureToggles({
|
|||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-gray-500">Current: </span>
|
<span className="text-gray-500">Current: </span>
|
||||||
<span className={`font-medium ${initial.ir ? 'text-green-600' : 'text-gray-600'}`}>
|
<span className={`font-medium ${initial.ir ? "text-green-600" : "text-gray-600"}`}>
|
||||||
{initial.ir ? 'Enabled' : 'Disabled'}
|
{initial.ir ? "Enabled" : "Disabled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-400">→</div>
|
<div className="text-gray-400">→</div>
|
||||||
<select
|
<select
|
||||||
value={ir ? 'Enabled' : 'Disabled'}
|
value={ir ? "Enabled" : "Disabled"}
|
||||||
onChange={(e) => setIr(e.target.value === 'Enabled')}
|
onChange={e => setIr(e.target.value === "Enabled")}
|
||||||
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
|
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
|
||||||
>
|
>
|
||||||
<option>Disabled</option>
|
<option>Disabled</option>
|
||||||
@ -194,8 +230,18 @@ export function SimFeatureToggles({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="bg-orange-100 rounded-lg p-2">
|
<div className="bg-orange-100 rounded-lg p-2">
|
||||||
<svg className="h-4 w-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
className="h-4 w-4 text-orange-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -212,7 +258,7 @@ export function SimFeatureToggles({
|
|||||||
<div className="text-gray-400">→</div>
|
<div className="text-gray-400">→</div>
|
||||||
<select
|
<select
|
||||||
value={nt}
|
value={nt}
|
||||||
onChange={(e) => setNt(e.target.value as '4G' | '5G')}
|
onChange={e => setNt(e.target.value as "4G" | "5G")}
|
||||||
className="block w-20 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
|
className="block w-20 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
|
||||||
>
|
>
|
||||||
<option value="4G">4G</option>
|
<option value="4G">4G</option>
|
||||||
@ -224,19 +270,34 @@ export function SimFeatureToggles({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes and Actions */}
|
{/* Notes and Actions */}
|
||||||
<div className={`${embedded ? '' : 'bg-white rounded-xl border border-gray-200 p-6'}`}>
|
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 p-6"}`}>
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<svg className="h-5 w-5 text-yellow-600 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-5 w-5 text-yellow-600 mt-0.5 mr-3 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="space-y-2 text-sm text-yellow-800">
|
<div className="space-y-2 text-sm text-yellow-800">
|
||||||
<p><strong>Important Notes:</strong></p>
|
<p>
|
||||||
|
<strong>Important Notes:</strong>
|
||||||
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
<ul className="list-disc list-inside space-y-1 ml-4">
|
||||||
<li>Changes will take effect instantaneously (approx. 30min)</li>
|
<li>Changes will take effect instantaneously (approx. 30min)</li>
|
||||||
<li>May require smartphone/device restart after changes are applied</li>
|
<li>May require smartphone/device restart after changes are applied</li>
|
||||||
<li>5G requires a compatible smartphone/device. Will not function on 4G devices</li>
|
<li>5G requires a compatible smartphone/device. Will not function on 4G devices</li>
|
||||||
<li>Changes to Voice Mail / Call Waiting must be requested before the 25th of the month</li>
|
<li>
|
||||||
|
Changes to Voice Mail / Call Waiting must be requested before the 25th of the
|
||||||
|
month
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -245,8 +306,18 @@ export function SimFeatureToggles({
|
|||||||
{success && (
|
{success && (
|
||||||
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="h-5 w-5 text-green-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-5 w-5 text-green-500 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-sm font-medium text-green-800">{success}</p>
|
<p className="text-sm font-medium text-green-800">{success}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -256,8 +327,18 @@ export function SimFeatureToggles({
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<svg className="h-5 w-5 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
className="h-5 w-5 text-red-500 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-sm font-medium text-red-800">{error}</p>
|
<p className="text-sm font-medium text-red-800">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -272,16 +353,36 @@ export function SimFeatureToggles({
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<svg className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
<svg
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25"></circle>
|
className="animate-spin -ml-1 mr-3 h-4 w-4 text-white"
|
||||||
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" className="opacity-75"></path>
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
className="opacity-25"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
className="opacity-75"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
Applying Changes...
|
Applying Changes...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Apply Changes
|
Apply Changes
|
||||||
</>
|
</>
|
||||||
@ -293,7 +394,12 @@ export function SimFeatureToggles({
|
|||||||
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"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ArrowPathIcon
|
ArrowPathIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from "@heroicons/react/24/outline";
|
||||||
import { SimDetailsCard, type SimDetails } from './SimDetailsCard';
|
import { SimDetailsCard, type SimDetails } from "./SimDetailsCard";
|
||||||
import { DataUsageChart, type SimUsage } from './DataUsageChart';
|
import { DataUsageChart, type SimUsage } from "./DataUsageChart";
|
||||||
import { SimActions } from './SimActions';
|
import { SimActions } from "./SimActions";
|
||||||
import { authenticatedApi } from '@/lib/api';
|
import { authenticatedApi } from "@/lib/api";
|
||||||
import { SimFeatureToggles } from './SimFeatureToggles';
|
import { SimFeatureToggles } from "./SimFeatureToggles";
|
||||||
|
|
||||||
interface SimManagementSectionProps {
|
interface SimManagementSectionProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
@ -29,19 +29,19 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
const fetchSimInfo = async () => {
|
const fetchSimInfo = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const data = await authenticatedApi.get<{
|
const data = await authenticatedApi.get<{
|
||||||
details: SimDetails;
|
details: SimDetails;
|
||||||
usage: SimUsage;
|
usage: SimUsage;
|
||||||
}>(`/subscriptions/${subscriptionId}/sim`);
|
}>(`/subscriptions/${subscriptionId}/sim`);
|
||||||
|
|
||||||
setSimInfo(data);
|
setSimInfo(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.status === 400) {
|
if (error.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(error instanceof Error ? error.message : "Failed to load SIM information");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -105,7 +105,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
<div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6">
|
<div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6">
|
||||||
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
|
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Unable to Load SIM Information</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">
|
||||||
|
Unable to Load SIM Information
|
||||||
|
</h3>
|
||||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
|
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
@ -180,8 +182,18 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
|
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<div className="bg-blue-200 rounded-lg p-2 mr-3">
|
<div className="bg-blue-200 rounded-lg p-2 mr-3">
|
||||||
<svg className="h-5 w-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
className="h-5 w-5 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-blue-900">Important Information</h3>
|
<h3 className="text-lg font-semibold text-blue-900">Important Information</h3>
|
||||||
@ -189,7 +201,8 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
<ul className="space-y-2 text-sm text-blue-800">
|
<ul className="space-y-2 text-sm text-blue-800">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
||||||
Data usage is updated in real-time and may take a few minutes to reflect recent activity
|
Data usage is updated in real-time and may take a few minutes to reflect recent
|
||||||
|
activity
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
||||||
@ -199,7 +212,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
||||||
SIM cancellation is permanent and cannot be undone
|
SIM cancellation is permanent and cannot be undone
|
||||||
</li>
|
</li>
|
||||||
{simInfo.details.simType === 'esim' && (
|
{simInfo.details.simType === "esim" && (
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
||||||
eSIM profile reissue will provide a new QR code for activation
|
eSIM profile reissue will provide a new QR code for activation
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import {
|
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
XMarkIcon,
|
import { authenticatedApi } from "@/lib/api";
|
||||||
PlusIcon,
|
|
||||||
ExclamationTriangleIcon
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import { authenticatedApi } from '@/lib/api';
|
|
||||||
|
|
||||||
interface TopUpModalProps {
|
interface TopUpModalProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
@ -16,7 +12,7 @@ interface TopUpModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
||||||
const [gbAmount, setGbAmount] = useState<string>('1');
|
const [gbAmount, setGbAmount] = useState<string>("1");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const getCurrentAmountMb = () => {
|
const getCurrentAmountMb = () => {
|
||||||
@ -36,9 +32,9 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!isValidAmount()) {
|
if (!isValidAmount()) {
|
||||||
onError('Please enter a whole number between 1 GB and 100 GB');
|
onError("Please enter a whole number between 1 GB and 100 GB");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +49,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
@ -69,7 +65,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
<div className="fixed inset-0 z-50 overflow-y-auto" onClick={handleBackdropClick}>
|
<div className="fixed inset-0 z-50 overflow-y-auto" onClick={handleBackdropClick}>
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||||
|
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
@ -94,14 +90,12 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* Amount Input */}
|
{/* Amount Input */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
||||||
Amount (GB)
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={gbAmount}
|
value={gbAmount}
|
||||||
onChange={(e) => setGbAmount(e.target.value)}
|
onChange={e => setGbAmount(e.target.value)}
|
||||||
placeholder="Enter amount in GB"
|
placeholder="Enter amount in GB"
|
||||||
min="1"
|
min="1"
|
||||||
max="50"
|
max="50"
|
||||||
@ -122,19 +116,15 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-blue-900">
|
<div className="text-sm font-medium text-blue-900">
|
||||||
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'}
|
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
|
||||||
</div>
|
|
||||||
<div className="text-xs text-blue-700">
|
|
||||||
= {getCurrentAmountMb()} MB
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-blue-700">= {getCurrentAmountMb()} MB</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-lg font-bold text-blue-900">
|
<div className="text-lg font-bold text-blue-900">
|
||||||
¥{calculateCost().toLocaleString()}
|
¥{calculateCost().toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-blue-700">
|
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
||||||
(1GB = ¥500)
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -166,7 +156,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
disabled={loading || !isValidAmount()}
|
disabled={loading || !isValidAmount()}
|
||||||
className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? 'Processing...' : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
export { SimManagementSection } from './components/SimManagementSection';
|
export { SimManagementSection } from "./components/SimManagementSection";
|
||||||
export { SimDetailsCard } from './components/SimDetailsCard';
|
export { SimDetailsCard } from "./components/SimDetailsCard";
|
||||||
export { DataUsageChart } from './components/DataUsageChart';
|
export { DataUsageChart } from "./components/DataUsageChart";
|
||||||
export { SimActions } from './components/SimActions';
|
export { SimActions } from "./components/SimActions";
|
||||||
export { TopUpModal } from './components/TopUpModal';
|
export { TopUpModal } from "./components/TopUpModal";
|
||||||
export { SimFeatureToggles } from './components/SimFeatureToggles';
|
export { SimFeatureToggles } from "./components/SimFeatureToggles";
|
||||||
|
|
||||||
export type { SimDetails } from './components/SimDetailsCard';
|
export type { SimDetails } from "./components/SimDetailsCard";
|
||||||
export type { SimUsage } from './components/DataUsageChart';
|
export type { SimUsage } from "./components/DataUsageChart";
|
||||||
|
|||||||
@ -19,9 +19,7 @@ export function QueryProvider({ children }: QueryProviderProps) {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
{enableDevtools && ReactQueryDevtools ? (
|
{enableDevtools && ReactQueryDevtools ? <ReactQueryDevtools initialIsOpen={false} /> : null}
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
|
||||||
) : null}
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user