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"));
|
||||
await this.redis.setex(`blacklist:${token}`, defaultTtl, "1");
|
||||
} catch (err) {
|
||||
this.logger.warn("Failed to write token to Redis blacklist; proceeding without persistence", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
this.logger.warn(
|
||||
"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)
|
||||
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
SF_PROVISION_EVENT_CHANNEL: z
|
||||
.string()
|
||||
.default("/event/Order_Fulfilment_Requested__e"),
|
||||
SF_PROVISION_EVENT_CHANNEL: z.string().default("/event/Order_Fulfilment_Requested__e"),
|
||||
SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"),
|
||||
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),
|
||||
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: {
|
||||
|
||||
street: process.env.ORDER_BILLING_STREET_FIELD || "BillingStreet",
|
||||
city: process.env.ORDER_BILLING_CITY_FIELD || "BillingCity",
|
||||
state: process.env.ORDER_BILLING_STATE_FIELD || "BillingState",
|
||||
postalCode: process.env.ORDER_BILLING_POSTAL_CODE_FIELD || "BillingPostalCode",
|
||||
country: process.env.ORDER_BILLING_COUNTRY_FIELD || "BillingCountry",
|
||||
|
||||
},
|
||||
},
|
||||
orderItem: {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { FreebititService } from '../vendors/freebit/freebit.service';
|
||||
import { WhmcsService } from '../vendors/whmcs/whmcs.service';
|
||||
import { MappingsService } from '../mappings/mappings.service';
|
||||
import { SubscriptionsService } from './subscriptions.service';
|
||||
import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types';
|
||||
import { SimUsageStoreService } from './sim-usage-store.service';
|
||||
import { getErrorMessage } from '../common/utils/error.util';
|
||||
import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { FreebititService } from "../vendors/freebit/freebit.service";
|
||||
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
||||
import { MappingsService } from "../mappings/mappings.service";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { SimDetails, SimUsage, SimTopUpHistory } from "../vendors/freebit/interfaces/freebit.types";
|
||||
import { SimUsageStoreService } from "./sim-usage-store.service";
|
||||
import { getErrorMessage } from "../common/utils/error.util";
|
||||
|
||||
export interface SimTopUpRequest {
|
||||
quotaMb: number;
|
||||
@ -22,14 +22,14 @@ export interface SimCancelRequest {
|
||||
|
||||
export interface SimTopUpHistoryRequest {
|
||||
fromDate: string; // YYYYMMDD
|
||||
toDate: string; // YYYYMMDD
|
||||
toDate: string; // YYYYMMDD
|
||||
}
|
||||
|
||||
export interface SimFeaturesUpdateRequest {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: '4G' | '5G';
|
||||
networkType?: "4G" | "5G";
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@ -40,7 +40,7 @@ export class SimManagementService {
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly usageStore: SimUsageStoreService,
|
||||
private readonly usageStore: SimUsageStoreService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -48,11 +48,14 @@ export class SimManagementService {
|
||||
*/
|
||||
async debugSimSubscription(userId: string, subscriptionId: number): Promise<any> {
|
||||
try {
|
||||
const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId);
|
||||
const subscription = await this.subscriptionsService.getSubscriptionById(
|
||||
userId,
|
||||
subscriptionId
|
||||
);
|
||||
|
||||
// Check for specific SIM data
|
||||
const expectedSimNumber = '02000331144508';
|
||||
const expectedEid = '89049032000001000000043598005455';
|
||||
const expectedSimNumber = "02000331144508";
|
||||
const expectedEid = "89049032000001000000043598005455";
|
||||
|
||||
const simNumberField = Object.entries(subscription.customFields || {}).find(
|
||||
([key, value]) => value && value.toString().includes(expectedSimNumber)
|
||||
@ -68,17 +71,20 @@ export class SimManagementService {
|
||||
domain: subscription.domain,
|
||||
orderNumber: subscription.orderNumber,
|
||||
customFields: subscription.customFields,
|
||||
isSimService: subscription.productName.toLowerCase().includes('sim') ||
|
||||
subscription.groupName?.toLowerCase().includes('sim'),
|
||||
isSimService:
|
||||
subscription.productName.toLowerCase().includes("sim") ||
|
||||
subscription.groupName?.toLowerCase().includes("sim"),
|
||||
groupName: subscription.groupName,
|
||||
status: subscription.status,
|
||||
// Specific SIM data checks
|
||||
expectedSimNumber,
|
||||
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,
|
||||
allCustomFieldKeys: Object.keys(subscription.customFields || {}),
|
||||
allCustomFieldValues: subscription.customFields
|
||||
allCustomFieldValues: subscription.customFields,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to debug subscription ${subscriptionId}`, {
|
||||
@ -91,21 +97,28 @@ export class SimManagementService {
|
||||
/**
|
||||
* 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 {
|
||||
// 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)
|
||||
const isSimService = subscription.productName.toLowerCase().includes('sim') ||
|
||||
subscription.groupName?.toLowerCase().includes('sim');
|
||||
const isSimService =
|
||||
subscription.productName.toLowerCase().includes("sim") ||
|
||||
subscription.groupName?.toLowerCase().includes("sim");
|
||||
|
||||
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
|
||||
let account = '';
|
||||
let account = "";
|
||||
|
||||
// 1. Try domain field first
|
||||
if (subscription.domain && subscription.domain.trim()) {
|
||||
@ -116,17 +129,45 @@ export class SimManagementService {
|
||||
if (!account && subscription.customFields) {
|
||||
// Common field names for SIM phone numbers in WHMCS
|
||||
const phoneFields = [
|
||||
'phone', 'msisdn', 'phonenumber', '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',
|
||||
"phone",
|
||||
"msisdn",
|
||||
"phonenumber",
|
||||
"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
|
||||
'SIM Number', 'SIM_Number', 'sim_number', 'SIM_Phone_Number',
|
||||
'Phone_Number_SIM', 'Mobile_SIM_Number', 'SIM_Account_Number',
|
||||
'ICCID', 'iccid', 'IMSI', 'imsi', 'EID', 'eid',
|
||||
"SIM Number",
|
||||
"SIM_Number",
|
||||
"sim_number",
|
||||
"SIM_Phone_Number",
|
||||
"Phone_Number_SIM",
|
||||
"Mobile_SIM_Number",
|
||||
"SIM_Account_Number",
|
||||
"ICCID",
|
||||
"iccid",
|
||||
"IMSI",
|
||||
"imsi",
|
||||
"EID",
|
||||
"eid",
|
||||
// Additional variations
|
||||
'02000331144508', // Direct match for your specific SIM number
|
||||
'SIM_Data', 'SIM_Info', 'SIM_Details'
|
||||
"02000331144508", // Direct match for your specific SIM number
|
||||
"SIM_Data",
|
||||
"SIM_Info",
|
||||
"SIM_Details",
|
||||
];
|
||||
|
||||
for (const fieldName of phoneFields) {
|
||||
@ -136,7 +177,7 @@ export class SimManagementService {
|
||||
userId,
|
||||
subscriptionId,
|
||||
fieldName,
|
||||
account
|
||||
account,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@ -144,22 +185,27 @@ export class SimManagementService {
|
||||
|
||||
// If still no account found, log all available custom fields for debugging
|
||||
if (!account) {
|
||||
this.logger.warn(`No SIM account found in custom fields for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
availableFields: Object.keys(subscription.customFields),
|
||||
customFields: subscription.customFields,
|
||||
searchedFields: phoneFields
|
||||
});
|
||||
this.logger.warn(
|
||||
`No SIM account found in custom fields for subscription ${subscriptionId}`,
|
||||
{
|
||||
userId,
|
||||
subscriptionId,
|
||||
availableFields: Object.keys(subscription.customFields),
|
||||
customFields: subscription.customFields,
|
||||
searchedFields: phoneFields,
|
||||
}
|
||||
);
|
||||
|
||||
// Check if any field contains the expected SIM number
|
||||
const expectedSimNumber = '02000331144508';
|
||||
const expectedSimNumber = "02000331144508";
|
||||
const foundSimNumber = Object.entries(subscription.customFields || {}).find(
|
||||
([key, value]) => value && value.toString().includes(expectedSimNumber)
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -176,20 +222,23 @@ export class SimManagementService {
|
||||
// 4. Final fallback - for testing, use the known test SIM number
|
||||
if (!account) {
|
||||
// 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}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
productName: subscription.productName,
|
||||
domain: subscription.domain,
|
||||
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
|
||||
note: 'Using known test SIM number 02000331144508 - should exist in Freebit test environment'
|
||||
});
|
||||
this.logger.warn(
|
||||
`No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`,
|
||||
{
|
||||
userId,
|
||||
subscriptionId,
|
||||
productName: subscription.productName,
|
||||
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.)
|
||||
account = account.replace(/[-\s()]/g, '');
|
||||
account = account.replace(/[-\s()]/g, "");
|
||||
|
||||
// Skip phone number format validation for testing
|
||||
// In production, you might want to add validation back:
|
||||
@ -203,14 +252,17 @@ export class SimManagementService {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
note: 'Phone number format validation skipped for testing'
|
||||
note: "Phone number format validation skipped for testing",
|
||||
});
|
||||
|
||||
return { account };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
this.logger.error(
|
||||
`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
}
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -264,7 +316,10 @@ export class SimManagementService {
|
||||
}));
|
||||
}
|
||||
} 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}`, {
|
||||
@ -295,7 +350,7 @@ export class SimManagementService {
|
||||
|
||||
// Validate quota amount
|
||||
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)
|
||||
@ -305,13 +360,15 @@ export class SimManagementService {
|
||||
|
||||
// Validate quota against Freebit API limits (100MB - 51200MB)
|
||||
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
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
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}`, {
|
||||
@ -328,7 +385,7 @@ export class SimManagementService {
|
||||
clientId: mapping.whmcsClientId,
|
||||
description: `SIM Data Top-up: ${units}GB for ${account}`,
|
||||
amount: costJpy,
|
||||
currency: 'JPY',
|
||||
currency: "JPY",
|
||||
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
||||
notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`,
|
||||
});
|
||||
@ -349,7 +406,7 @@ export class SimManagementService {
|
||||
const paymentResult = await this.whmcsService.capturePayment({
|
||||
invoiceId: invoice.id,
|
||||
amount: costJpy,
|
||||
currency: 'JPY',
|
||||
currency: "JPY",
|
||||
});
|
||||
|
||||
if (!paymentResult.success) {
|
||||
@ -363,19 +420,19 @@ export class SimManagementService {
|
||||
try {
|
||||
await this.whmcsService.updateInvoice({
|
||||
invoiceId: invoice.id,
|
||||
status: 'Cancelled',
|
||||
notes: `Payment capture failed: ${paymentResult.error}. Invoice cancelled automatically.`
|
||||
status: "Cancelled",
|
||||
notes: `Payment capture failed: ${paymentResult.error}. Invoice cancelled automatically.`,
|
||||
});
|
||||
|
||||
this.logger.log(`Cancelled invoice ${invoice.id} due to payment failure`, {
|
||||
invoiceId: invoice.id,
|
||||
reason: 'Payment capture failed'
|
||||
reason: "Payment capture failed",
|
||||
});
|
||||
} catch (cancelError) {
|
||||
this.logger.error(`Failed to cancel invoice ${invoice.id} after payment failure`, {
|
||||
invoiceId: invoice.id,
|
||||
cancelError: getErrorMessage(cancelError),
|
||||
originalError: paymentResult.error
|
||||
originalError: paymentResult.error,
|
||||
});
|
||||
}
|
||||
|
||||
@ -408,33 +465,36 @@ export class SimManagementService {
|
||||
// 1. Create a refund/credit
|
||||
// 2. Send notification to admin
|
||||
// 3. Queue for retry
|
||||
this.logger.error(`Freebit API failed after successful payment for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(freebititError),
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
quotaMb: request.quotaMb,
|
||||
invoiceId: invoice.id,
|
||||
transactionId: paymentResult.transactionId,
|
||||
paymentCaptured: true,
|
||||
});
|
||||
this.logger.error(
|
||||
`Freebit API failed after successful payment for subscription ${subscriptionId}`,
|
||||
{
|
||||
error: getErrorMessage(freebititError),
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
quotaMb: request.quotaMb,
|
||||
invoiceId: invoice.id,
|
||||
transactionId: paymentResult.transactionId,
|
||||
paymentCaptured: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add a note to the invoice about the Freebit failure
|
||||
try {
|
||||
await this.whmcsService.updateInvoice({
|
||||
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}`, {
|
||||
invoiceId: invoice.id,
|
||||
reason: 'Freebit API failure after payment'
|
||||
reason: "Freebit API failure after payment",
|
||||
});
|
||||
} catch (updateError) {
|
||||
this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, {
|
||||
invoiceId: invoice.id,
|
||||
updateError: getErrorMessage(updateError),
|
||||
originalError: getErrorMessage(freebititError)
|
||||
originalError: getErrorMessage(freebititError),
|
||||
});
|
||||
}
|
||||
|
||||
@ -474,7 +534,7 @@ export class SimManagementService {
|
||||
|
||||
// Validate date format
|
||||
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(
|
||||
@ -514,7 +574,7 @@ export class SimManagementService {
|
||||
|
||||
// Validate plan code format
|
||||
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
|
||||
@ -524,8 +584,8 @@ export class SimManagementService {
|
||||
|
||||
// Format as YYYYMMDD for Freebit API
|
||||
const year = nextMonth.getFullYear();
|
||||
const month = String(nextMonth.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(nextMonth.getDate()).padStart(2, '0');
|
||||
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(nextMonth.getDate()).padStart(2, "0");
|
||||
const scheduledAt = `${year}${month}${day}`;
|
||||
|
||||
this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, {
|
||||
@ -573,7 +633,7 @@ export class SimManagementService {
|
||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||
|
||||
// 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"');
|
||||
}
|
||||
|
||||
@ -599,13 +659,17 @@ export class SimManagementService {
|
||||
/**
|
||||
* Cancel SIM service
|
||||
*/
|
||||
async cancelSim(userId: string, subscriptionId: number, request: SimCancelRequest = {}): Promise<void> {
|
||||
async cancelSim(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimCancelRequest = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||
|
||||
// Validate scheduled date if provided
|
||||
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);
|
||||
@ -634,8 +698,8 @@ export class SimManagementService {
|
||||
|
||||
// First check if this is actually an eSIM
|
||||
const simDetails = await this.freebititService.getSimDetails(account);
|
||||
if (simDetails.simType !== 'esim') {
|
||||
throw new BadRequestException('This operation is only available for eSIM subscriptions');
|
||||
if (simDetails.simType !== "esim") {
|
||||
throw new BadRequestException("This operation is only available for eSIM subscriptions");
|
||||
}
|
||||
|
||||
await this.freebititService.reissueEsimProfile(account);
|
||||
@ -658,7 +722,10 @@ export class SimManagementService {
|
||||
/**
|
||||
* Get comprehensive SIM information (details + usage combined)
|
||||
*/
|
||||
async getSimInfo(userId: string, subscriptionId: number): Promise<{
|
||||
async getSimInfo(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<{
|
||||
details: SimDetails;
|
||||
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)
|
||||
// by subtracting measured usage (today + recentDays) from the plan cap.
|
||||
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) {
|
||||
const capGb = parseInt(planCapMatch[1], 10);
|
||||
if (!isNaN(capGb) && capGb > 0) {
|
||||
@ -706,25 +775,25 @@ export class SimManagementService {
|
||||
const errorLower = technicalError.toLowerCase();
|
||||
|
||||
// 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.";
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
|
||||
if (errorLower.includes('timeout') || errorLower.includes('network')) {
|
||||
if (errorLower.includes("timeout") || errorLower.includes("network")) {
|
||||
return "SIM service request timed out. Please try again.";
|
||||
}
|
||||
|
||||
// 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.";
|
||||
}
|
||||
|
||||
// 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.";
|
||||
}
|
||||
|
||||
|
||||
@ -6,14 +6,14 @@ import { Logger } from "nestjs-pino";
|
||||
export class SimUsageStoreService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private normalizeDate(date?: Date): Date {
|
||||
const d = date ? new Date(date) : new Date();
|
||||
// strip time to YYYY-MM-DD
|
||||
const iso = d.toISOString().split('T')[0];
|
||||
return new Date(iso + 'T00:00:00.000Z');
|
||||
const iso = d.toISOString().split("T")[0];
|
||||
return new Date(iso + "T00:00:00.000Z");
|
||||
}
|
||||
|
||||
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 start = new Date(end);
|
||||
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 } },
|
||||
orderBy: { date: 'desc' },
|
||||
}) as Array<{ date: Date; usageMb: number }>;
|
||||
return rows.map((r) => ({ date: r.date.toISOString().split('T')[0], usageMb: r.usageMb }));
|
||||
orderBy: { date: "desc" },
|
||||
})) as Array<{ date: Date; usageMb: number }>;
|
||||
return rows.map(r => ({ date: r.date.toISOString().split("T")[0], usageMb: r.usageMb }));
|
||||
}
|
||||
|
||||
async cleanupPreviousMonths(): Promise<number> {
|
||||
const now = new Date();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ import type { RequestWithUser } from "../auth/auth.types";
|
||||
export class SubscriptionsController {
|
||||
constructor(
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
private readonly simManagementService: SimManagementService,
|
||||
private readonly simManagementService: SimManagementService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -297,7 +297,8 @@ export class SubscriptionsController {
|
||||
async topUpSim(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: {
|
||||
@Body()
|
||||
body: {
|
||||
quotaMb: number;
|
||||
}
|
||||
) {
|
||||
@ -308,7 +309,8 @@ export class SubscriptionsController {
|
||||
@Post(":id/sim/change-plan")
|
||||
@ApiOperation({
|
||||
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" })
|
||||
@ApiBody({
|
||||
@ -325,7 +327,8 @@ export class SubscriptionsController {
|
||||
async changeSimPlan(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: {
|
||||
@Body()
|
||||
body: {
|
||||
newPlanCode: string;
|
||||
}
|
||||
) {
|
||||
@ -333,7 +336,7 @@ export class SubscriptionsController {
|
||||
return {
|
||||
success: true,
|
||||
message: "SIM plan change completed successfully",
|
||||
...result
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
@ -348,7 +351,11 @@ export class SubscriptionsController {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
scheduledAt: { type: "string", description: "Schedule cancellation (YYYYMMDD)", example: "20241231" },
|
||||
scheduledAt: {
|
||||
type: "string",
|
||||
description: "Schedule cancellation (YYYYMMDD)",
|
||||
example: "20241231",
|
||||
},
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
@ -382,7 +389,8 @@ export class SubscriptionsController {
|
||||
@Post(":id/sim/features")
|
||||
@ApiOperation({
|
||||
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" })
|
||||
@ApiBody({
|
||||
@ -406,7 +414,7 @@ export class SubscriptionsController {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: '4G' | '5G';
|
||||
networkType?: "4G" | "5G";
|
||||
}
|
||||
) {
|
||||
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FreebititService } from './freebit.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { FreebititService } from "./freebit.service";
|
||||
|
||||
@Module({
|
||||
providers: [FreebititService],
|
||||
|
||||
388
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
388
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
@ -1,6 +1,11 @@
|
||||
import { Injectable, Inject, BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
FreebititConfig,
|
||||
FreebititAuthRequest,
|
||||
@ -26,8 +31,8 @@ import {
|
||||
SimTopUpHistory,
|
||||
FreebititError,
|
||||
FreebititAddSpecRequest,
|
||||
FreebititAddSpecResponse
|
||||
} from './interfaces/freebit.types';
|
||||
FreebititAddSpecResponse,
|
||||
} from "./interfaces/freebit.types";
|
||||
|
||||
@Injectable()
|
||||
export class FreebititService {
|
||||
@ -39,23 +44,25 @@ export class FreebititService {
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
this.config = {
|
||||
baseUrl: this.configService.get<string>('FREEBIT_BASE_URL') || 'https://i1-q.mvno.net/emptool/api/',
|
||||
oemId: this.configService.get<string>('FREEBIT_OEM_ID') || 'PASI',
|
||||
oemKey: this.configService.get<string>('FREEBIT_OEM_KEY') || '',
|
||||
timeout: this.configService.get<number>('FREEBIT_TIMEOUT') || 30000,
|
||||
retryAttempts: this.configService.get<number>('FREEBIT_RETRY_ATTEMPTS') || 3,
|
||||
detailsEndpoint: this.configService.get<string>('FREEBIT_DETAILS_ENDPOINT') || '/master/getAcnt/',
|
||||
baseUrl:
|
||||
this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1-q.mvno.net/emptool/api/",
|
||||
oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI",
|
||||
oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "",
|
||||
timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000,
|
||||
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
|
||||
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,
|
||||
oemId: this.config.oemId,
|
||||
hasOemKey: !!this.config.oemKey,
|
||||
@ -65,19 +72,19 @@ export class FreebititService {
|
||||
/**
|
||||
* 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) {
|
||||
case 'active':
|
||||
return 'active';
|
||||
case 'suspended':
|
||||
return 'suspended';
|
||||
case 'temporary':
|
||||
case 'waiting':
|
||||
return 'pending';
|
||||
case 'obsolete':
|
||||
return 'cancelled';
|
||||
case "active":
|
||||
return "active";
|
||||
case "suspended":
|
||||
return "suspended";
|
||||
case "temporary":
|
||||
case "waiting":
|
||||
return "pending";
|
||||
case "obsolete":
|
||||
return "cancelled";
|
||||
default:
|
||||
return 'pending';
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,7 +100,7 @@ export class FreebititService {
|
||||
try {
|
||||
// Check if configuration is available
|
||||
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 = {
|
||||
@ -102,9 +109,9 @@ export class FreebititService {
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `json=${JSON.stringify(request)}`,
|
||||
});
|
||||
@ -113,9 +120,9 @@ export class FreebititService {
|
||||
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(
|
||||
`Authentication failed: ${data.status.message}`,
|
||||
data.resultCode,
|
||||
@ -130,30 +137,27 @@ export class FreebititService {
|
||||
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;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to authenticate with Freebit API', { error: error.message });
|
||||
throw new InternalServerErrorException('Failed to authenticate with Freebit API');
|
||||
this.logger.error("Failed to authenticate with Freebit API", { error: error.message });
|
||||
throw new InternalServerErrorException("Failed to authenticate with Freebit API");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make authenticated API request with error handling
|
||||
*/
|
||||
private async makeAuthenticatedRequest<T>(
|
||||
endpoint: string,
|
||||
data: any
|
||||
): Promise<T> {
|
||||
private async makeAuthenticatedRequest<T>(endpoint: string, data: any): Promise<T> {
|
||||
const authKey = await this.getAuthKey();
|
||||
const requestData = { ...data, authKey };
|
||||
|
||||
try {
|
||||
const url = `${this.config.baseUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: `json=${JSON.stringify(requestData)}`,
|
||||
});
|
||||
@ -164,7 +168,7 @@ export class FreebititService {
|
||||
const text = await response.text();
|
||||
bodySnippet = text ? text.slice(0, 500) : undefined;
|
||||
} catch {}
|
||||
this.logger.error('Freebit API non-OK response', {
|
||||
this.logger.error("Freebit API non-OK response", {
|
||||
endpoint,
|
||||
url,
|
||||
status: response.status,
|
||||
@ -174,29 +178,29 @@ export class FreebititService {
|
||||
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
|
||||
if (responseData && (responseData as any).resultCode !== '100') {
|
||||
if (responseData && (responseData as any).resultCode !== "100") {
|
||||
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
|
||||
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.`;
|
||||
} 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.`;
|
||||
} 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.`;
|
||||
}
|
||||
|
||||
this.logger.error('Freebit API error response', {
|
||||
this.logger.error("Freebit API error response", {
|
||||
endpoint,
|
||||
resultCode: errorData.resultCode,
|
||||
statusCode: errorData.status?.statusCode,
|
||||
message: errorMessage,
|
||||
userFriendlyMessage
|
||||
userFriendlyMessage,
|
||||
});
|
||||
|
||||
throw new FreebititErrorImpl(
|
||||
@ -207,7 +211,7 @@ export class FreebititService {
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug('Freebit API Request Success', {
|
||||
this.logger.debug("Freebit API Request Success", {
|
||||
endpoint,
|
||||
resultCode: (responseData as any).resultCode,
|
||||
});
|
||||
@ -218,8 +222,12 @@ export class FreebititService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: (error as any).message });
|
||||
throw new InternalServerErrorException(`Freebit API request failed: ${(error as any).message}`);
|
||||
this.logger.error(`Freebit API request failed: ${endpoint}`, {
|
||||
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> {
|
||||
try {
|
||||
const request: Omit<FreebititAccountDetailsRequest, 'authKey'> = {
|
||||
version: '2',
|
||||
requestDatas: [{ kind: 'MVNO', account }],
|
||||
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
|
||||
version: "2",
|
||||
requestDatas: [{ kind: "MVNO", account }],
|
||||
};
|
||||
|
||||
const configured = this.config.detailsEndpoint || '/master/getAcnt/';
|
||||
const candidates = Array.from(new Set([
|
||||
configured,
|
||||
configured.replace(/\/$/, ''),
|
||||
'/master/getAcnt/',
|
||||
'/master/getAcnt',
|
||||
'/mvno/getAccountDetail/',
|
||||
'/mvno/getAccountDetail',
|
||||
'/mvno/getAcntDetail/',
|
||||
'/mvno/getAcntDetail',
|
||||
'/mvno/getAccountInfo/',
|
||||
'/mvno/getAccountInfo',
|
||||
'/mvno/getSubscriberInfo/',
|
||||
'/mvno/getSubscriberInfo',
|
||||
'/mvno/getInfo/',
|
||||
'/mvno/getInfo',
|
||||
'/master/getDetail/',
|
||||
'/master/getDetail',
|
||||
]));
|
||||
const configured = this.config.detailsEndpoint || "/master/getAcnt/";
|
||||
const candidates = Array.from(
|
||||
new Set([
|
||||
configured,
|
||||
configured.replace(/\/$/, ""),
|
||||
"/master/getAcnt/",
|
||||
"/master/getAcnt",
|
||||
"/mvno/getAccountDetail/",
|
||||
"/mvno/getAccountDetail",
|
||||
"/mvno/getAcntDetail/",
|
||||
"/mvno/getAcntDetail",
|
||||
"/mvno/getAccountInfo/",
|
||||
"/mvno/getAccountInfo",
|
||||
"/mvno/getSubscriberInfo/",
|
||||
"/mvno/getSubscriberInfo",
|
||||
"/mvno/getInfo/",
|
||||
"/mvno/getInfo",
|
||||
"/master/getDetail/",
|
||||
"/master/getDetail",
|
||||
])
|
||||
);
|
||||
|
||||
let response: FreebititAccountDetailsResponse | undefined;
|
||||
let lastError: any;
|
||||
@ -260,11 +270,14 @@ export class FreebititService {
|
||||
if (ep !== candidates[0]) {
|
||||
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
|
||||
} catch (err: any) {
|
||||
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
|
||||
continue;
|
||||
}
|
||||
@ -274,22 +287,27 @@ export class FreebititService {
|
||||
}
|
||||
|
||||
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 list = Array.isArray(datas) ? datas : (datas ? [datas] : []);
|
||||
const list = Array.isArray(datas) ? datas : datas ? [datas] : [];
|
||||
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
|
||||
const mvno = list.find((d: any) => (d.kind || '').toString().toUpperCase() === 'MVNO') || list[0];
|
||||
const simData = mvno as any;
|
||||
const mvno =
|
||||
list.find((d: any) => (d.kind || "").toString().toUpperCase() === "MVNO") || list[0];
|
||||
const simData = mvno;
|
||||
|
||||
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
|
||||
const startDate = startDateRaw && /^\d{8}$/.test(startDateRaw)
|
||||
? `${startDateRaw.slice(0,4)}-${startDateRaw.slice(4,6)}-${startDateRaw.slice(6,8)}`
|
||||
: startDateRaw;
|
||||
const startDate =
|
||||
startDateRaw && /^\d{8}$/.test(startDateRaw)
|
||||
? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}`
|
||||
: startDateRaw;
|
||||
|
||||
const simDetails: SimDetails = {
|
||||
account: String(simData.account ?? account),
|
||||
@ -298,13 +316,14 @@ export class FreebititService {
|
||||
imsi: simData.imsi ? String(simData.imsi) : undefined,
|
||||
eid: simData.eid,
|
||||
planCode: simData.planCode,
|
||||
status: this.mapSimStatus(String(simData.state || 'pending')),
|
||||
simType: simData.eid ? 'esim' : 'physical',
|
||||
status: this.mapSimStatus(String(simData.state || "pending")),
|
||||
simType: simData.eid ? "esim" : "physical",
|
||||
size: simData.size,
|
||||
hasVoice: simData.talk === 10,
|
||||
hasSms: simData.sms === 10,
|
||||
remainingQuotaKb: typeof simData.quota === 'number' ? simData.quota : 0,
|
||||
remainingQuotaMb: typeof simData.quota === 'number' ? Math.round((simData.quota / 1000) * 100) / 100 : 0,
|
||||
remainingQuotaKb: typeof simData.quota === "number" ? simData.quota : 0,
|
||||
remainingQuotaMb:
|
||||
typeof simData.quota === "number" ? Math.round((simData.quota / 1000) * 100) / 100 : 0,
|
||||
startDate,
|
||||
ipv4: simData.ipv4,
|
||||
ipv6: simData.ipv6,
|
||||
@ -312,10 +331,14 @@ export class FreebititService {
|
||||
callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10,
|
||||
internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10,
|
||||
networkType: simData.contractLine || undefined,
|
||||
pendingOperations: simData.async ? [{
|
||||
operation: simData.async.func,
|
||||
scheduledDate: String(simData.async.date),
|
||||
}] : undefined,
|
||||
pendingOperations: simData.async
|
||||
? [
|
||||
{
|
||||
operation: simData.async.func,
|
||||
scheduledDate: String(simData.async.date),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
};
|
||||
|
||||
this.logger.log(`Retrieved SIM details for account ${account}`, {
|
||||
@ -326,7 +349,9 @@ export class FreebititService {
|
||||
|
||||
return simDetails;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@ -336,26 +361,26 @@ export class FreebititService {
|
||||
*/
|
||||
async getSimUsage(account: string): Promise<SimUsage> {
|
||||
try {
|
||||
const request: Omit<FreebititTrafficInfoRequest, 'authKey'> = { account };
|
||||
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
|
||||
|
||||
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
|
||||
'/mvno/getTrafficInfo/',
|
||||
"/mvno/getTrafficInfo/",
|
||||
request
|
||||
);
|
||||
|
||||
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
||||
const recentDaysData = response.traffic.inRecentDays.split(',').map((usage, index) => ({
|
||||
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
||||
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[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 = {
|
||||
account,
|
||||
todayUsageKb,
|
||||
todayUsageMb: Math.round(todayUsageKb / 1000 * 100) / 100,
|
||||
todayUsageMb: Math.round((todayUsageKb / 1000) * 100) / 100,
|
||||
recentDaysUsage: recentDaysData,
|
||||
isBlacklisted: response.traffic.blackList === '10',
|
||||
isBlacklisted: response.traffic.blackList === "10",
|
||||
};
|
||||
|
||||
this.logger.log(`Retrieved SIM usage for account ${account}`, {
|
||||
@ -374,11 +399,15 @@ export class FreebititService {
|
||||
/**
|
||||
* Top up SIM data quota
|
||||
*/
|
||||
async topUpSim(account: string, quotaMb: number, options: {
|
||||
campaignCode?: string;
|
||||
expiryDate?: string;
|
||||
scheduledAt?: string;
|
||||
} = {}): Promise<void> {
|
||||
async topUpSim(
|
||||
account: string,
|
||||
quotaMb: number,
|
||||
options: {
|
||||
campaignCode?: string;
|
||||
expiryDate?: string;
|
||||
scheduledAt?: string;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Units per endpoint:
|
||||
// - 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 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) {
|
||||
// PA05-22: KB + runTime
|
||||
request = {
|
||||
@ -404,7 +433,7 @@ export class FreebititService {
|
||||
// PA04-04: MB + kind
|
||||
request = {
|
||||
account,
|
||||
kind: 'MVNO',
|
||||
kind: "MVNO",
|
||||
quota: quotaMbStr,
|
||||
quotaCode: options.campaignCode,
|
||||
expire: options.expiryDate,
|
||||
@ -418,7 +447,7 @@ export class FreebititService {
|
||||
endpoint,
|
||||
quotaMb,
|
||||
quotaKb,
|
||||
units: isScheduled ? 'KB (PA05-22)' : 'MB (PA04-04)',
|
||||
units: isScheduled ? "KB (PA05-22)" : "MB (PA04-04)",
|
||||
campaignCode: options.campaignCode,
|
||||
scheduled: isScheduled,
|
||||
});
|
||||
@ -435,16 +464,20 @@ export class FreebititService {
|
||||
/**
|
||||
* 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 {
|
||||
const request: Omit<FreebititQuotaHistoryRequest, 'authKey'> = {
|
||||
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = {
|
||||
account,
|
||||
fromDate,
|
||||
toDate,
|
||||
};
|
||||
|
||||
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
|
||||
'/mvno/getQuotaHistory/',
|
||||
"/mvno/getQuotaHistory/",
|
||||
request
|
||||
);
|
||||
|
||||
@ -454,7 +487,7 @@ export class FreebititService {
|
||||
additionCount: response.count,
|
||||
history: response.quotaHistory.map(item => ({
|
||||
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,
|
||||
expiryDate: item.expire,
|
||||
campaignCode: item.quotaCode,
|
||||
@ -469,7 +502,9 @@ export class FreebititService {
|
||||
|
||||
return history;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@ -477,20 +512,24 @@ export class FreebititService {
|
||||
/**
|
||||
* Change SIM plan
|
||||
*/
|
||||
async changeSimPlan(account: string, newPlanCode: string, options: {
|
||||
assignGlobalIp?: boolean;
|
||||
scheduledAt?: string;
|
||||
} = {}): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||
async changeSimPlan(
|
||||
account: string,
|
||||
newPlanCode: string,
|
||||
options: {
|
||||
assignGlobalIp?: boolean;
|
||||
scheduledAt?: string;
|
||||
} = {}
|
||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||
try {
|
||||
const request: Omit<FreebititPlanChangeRequest, 'authKey'> = {
|
||||
const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
|
||||
account,
|
||||
planCode: newPlanCode,
|
||||
globalip: options.assignGlobalIp ? '1' : '0',
|
||||
globalip: options.assignGlobalIp ? "1" : "0",
|
||||
runTime: options.scheduledAt,
|
||||
};
|
||||
|
||||
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
|
||||
'/mvno/changePlan/',
|
||||
"/mvno/changePlan/",
|
||||
request
|
||||
);
|
||||
|
||||
@ -519,35 +558,40 @@ export class FreebititService {
|
||||
* Update SIM optional features (voicemail, call waiting, international roaming, network type)
|
||||
* Uses AddSpec endpoint for immediate changes
|
||||
*/
|
||||
async updateSimFeatures(account: string, features: {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: string; // '4G' | '5G'
|
||||
}): Promise<void> {
|
||||
async updateSimFeatures(
|
||||
account: string,
|
||||
features: {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: string; // '4G' | '5G'
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebititAddSpecRequest, 'authKey'> = {
|
||||
const request: Omit<FreebititAddSpecRequest, "authKey"> = {
|
||||
account,
|
||||
kind: 'MVNO',
|
||||
kind: "MVNO",
|
||||
};
|
||||
|
||||
if (typeof features.voiceMailEnabled === 'boolean') {
|
||||
request.voiceMail = features.voiceMailEnabled ? '10' as const : '20' as const;
|
||||
if (typeof features.voiceMailEnabled === "boolean") {
|
||||
request.voiceMail = features.voiceMailEnabled ? ("10" as const) : ("20" as const);
|
||||
request.voicemail = request.voiceMail; // include alternate casing for compatibility
|
||||
}
|
||||
if (typeof features.callWaitingEnabled === 'boolean') {
|
||||
request.callWaiting = features.callWaitingEnabled ? '10' as const : '20' as const;
|
||||
if (typeof features.callWaitingEnabled === "boolean") {
|
||||
request.callWaiting = features.callWaitingEnabled ? ("10" as const) : ("20" as const);
|
||||
request.callwaiting = request.callWaiting;
|
||||
}
|
||||
if (typeof features.internationalRoamingEnabled === 'boolean') {
|
||||
request.worldWing = features.internationalRoamingEnabled ? '10' as const : '20' as const;
|
||||
if (typeof features.internationalRoamingEnabled === "boolean") {
|
||||
request.worldWing = features.internationalRoamingEnabled
|
||||
? ("10" as const)
|
||||
: ("20" as const);
|
||||
request.worldwing = request.worldWing;
|
||||
}
|
||||
if (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}`, {
|
||||
account,
|
||||
@ -570,13 +614,13 @@ export class FreebititService {
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebititCancelPlanRequest, 'authKey'> = {
|
||||
const request: Omit<FreebititCancelPlanRequest, "authKey"> = {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
};
|
||||
|
||||
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
|
||||
'/mvno/releasePlan/',
|
||||
"/mvno/releasePlan/",
|
||||
request
|
||||
);
|
||||
|
||||
@ -603,59 +647,68 @@ export class FreebititService {
|
||||
|
||||
// Fetch details to get current EID and plan/network where available
|
||||
const details = await this.getSimDetails(account);
|
||||
if (details.simType !== 'esim') {
|
||||
throw new BadRequestException('This operation is only available for eSIM subscriptions');
|
||||
if (details.simType !== "esim") {
|
||||
throw new BadRequestException("This operation is only available for eSIM subscriptions");
|
||||
}
|
||||
|
||||
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,
|
||||
aladinOperated: '20',
|
||||
createType: 'reissue',
|
||||
aladinOperated: "20",
|
||||
createType: "reissue",
|
||||
eid: details.eid, // existing EID used for reissue
|
||||
account,
|
||||
simkind: 'esim',
|
||||
addKind: 'R',
|
||||
simkind: "esim",
|
||||
addKind: "R",
|
||||
// Optional enrichments omitted to minimize validation mismatches
|
||||
};
|
||||
|
||||
const url = `${this.config.baseUrl}/mvno/esim/addAcct/`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
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) });
|
||||
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),
|
||||
});
|
||||
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as import('./interfaces/freebit.types').FreebititEsimAccountActivationResponse;
|
||||
const rc = typeof data.resultCode === 'number' ? String(data.resultCode) : (data.resultCode || '');
|
||||
if (rc !== '100') {
|
||||
const message = data.message || 'Unknown error';
|
||||
this.logger.error('Freebit PA05-41 API error response', {
|
||||
endpoint: '/mvno/esim/addAcct/',
|
||||
const data =
|
||||
(await response.json()) as import("./interfaces/freebit.types").FreebititEsimAccountActivationResponse;
|
||||
const rc =
|
||||
typeof data.resultCode === "number" ? String(data.resultCode) : data.resultCode || "";
|
||||
if (rc !== "100") {
|
||||
const message = data.message || "Unknown error";
|
||||
this.logger.error("Freebit PA05-41 API error response", {
|
||||
endpoint: "/mvno/esim/addAcct/",
|
||||
resultCode: data.resultCode,
|
||||
statusCode: data.statusCode,
|
||||
message,
|
||||
});
|
||||
throw new FreebititErrorImpl(
|
||||
`API Error: ${message}`,
|
||||
rc || '0',
|
||||
data.statusCode || '0',
|
||||
rc || "0",
|
||||
data.statusCode || "0",
|
||||
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) {
|
||||
if (error instanceof BadRequestException) throw error;
|
||||
this.logger.error(`Failed to reissue eSIM profile via PA05-41 for account ${account}`, {
|
||||
@ -679,11 +732,11 @@ export class FreebititService {
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebititEsimAddAccountRequest, 'authKey'> = {
|
||||
aladinOperated: '20',
|
||||
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
|
||||
aladinOperated: "20",
|
||||
account,
|
||||
eid: newEid,
|
||||
addKind: 'R', // R = reissue
|
||||
addKind: "R", // R = reissue
|
||||
reissue: {
|
||||
oldProductNumber: options.oldProductNumber,
|
||||
oldEid: options.oldEid,
|
||||
@ -696,7 +749,7 @@ export class FreebititService {
|
||||
}
|
||||
|
||||
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
|
||||
'/mvno/esim/addAcnt/',
|
||||
"/mvno/esim/addAcnt/",
|
||||
request
|
||||
);
|
||||
|
||||
@ -724,7 +777,7 @@ export class FreebititService {
|
||||
await this.getAuthKey();
|
||||
return true;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@ -736,14 +789,9 @@ class FreebititErrorImpl extends Error {
|
||||
public readonly statusCode: string;
|
||||
public readonly freebititMessage: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
resultCode: string,
|
||||
statusCode: string,
|
||||
freebititMessage: string
|
||||
) {
|
||||
constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) {
|
||||
super(message);
|
||||
this.name = 'FreebititError';
|
||||
this.name = "FreebititError";
|
||||
this.resultCode = resultCode;
|
||||
this.statusCode = statusCode;
|
||||
this.freebititMessage = freebititMessage;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// Freebit API Type Definitions
|
||||
|
||||
export interface FreebititAuthRequest {
|
||||
oemId: string; // 4-char alphanumeric ISP identifier
|
||||
oemKey: string; // 32-char auth key
|
||||
oemId: string; // 4-char alphanumeric ISP identifier
|
||||
oemKey: string; // 32-char auth key
|
||||
}
|
||||
|
||||
export interface FreebititAuthResponse {
|
||||
@ -11,14 +11,14 @@ export interface FreebititAuthResponse {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
authKey: string; // Token for subsequent API calls
|
||||
authKey: string; // Token for subsequent API calls
|
||||
}
|
||||
|
||||
export interface FreebititAccountDetailsRequest {
|
||||
authKey: string;
|
||||
version?: string | number; // Docs recommend "2"
|
||||
requestDatas: Array<{
|
||||
kind: 'MASTER' | 'MVNO' | string;
|
||||
kind: "MASTER" | "MVNO" | string;
|
||||
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
|
||||
responseDatas:
|
||||
| {
|
||||
kind: 'MASTER' | 'MVNO' | string;
|
||||
kind: "MASTER" | "MVNO" | string;
|
||||
account: string | number;
|
||||
state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string;
|
||||
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
|
||||
startDate?: string | number;
|
||||
relationCode?: string;
|
||||
resultCode?: string | number;
|
||||
@ -44,21 +44,21 @@ export interface FreebititAccountDetailsResponse {
|
||||
imsi?: string | number;
|
||||
eid?: string;
|
||||
contractLine?: string;
|
||||
size?: 'standard' | 'nano' | 'micro' | 'esim' | string;
|
||||
size?: "standard" | "nano" | "micro" | "esim" | string;
|
||||
sms?: number; // 10=active, 20=inactive
|
||||
talk?: number; // 10=active, 20=inactive
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
quota?: number; // Remaining quota (units vary by env)
|
||||
async?: {
|
||||
func: 'regist' | 'stop' | 'resume' | 'cancel' | 'pinset' | 'pinunset' | string;
|
||||
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
|
||||
date: string | number;
|
||||
};
|
||||
}
|
||||
| Array<{
|
||||
kind: 'MASTER' | 'MVNO' | string;
|
||||
kind: "MASTER" | "MVNO" | string;
|
||||
account: string | number;
|
||||
state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string;
|
||||
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
|
||||
startDate?: string | number;
|
||||
relationCode?: string;
|
||||
resultCode?: string | number;
|
||||
@ -67,17 +67,17 @@ export interface FreebititAccountDetailsResponse {
|
||||
imsi?: string | number;
|
||||
eid?: string;
|
||||
contractLine?: string;
|
||||
size?: 'standard' | 'nano' | 'micro' | 'esim' | string;
|
||||
size?: "standard" | "nano" | "micro" | "esim" | string;
|
||||
sms?: number;
|
||||
talk?: number;
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
quota?: number;
|
||||
async?: {
|
||||
func: 'regist' | 'stop' | 'resume' | 'cancel' | 'pinset' | 'pinunset' | string;
|
||||
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
|
||||
date: string | number;
|
||||
};
|
||||
}>
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FreebititTrafficInfoRequest {
|
||||
@ -93,9 +93,9 @@ export interface FreebititTrafficInfoResponse {
|
||||
};
|
||||
account: string;
|
||||
traffic: {
|
||||
today: string; // Today's usage in KB
|
||||
today: string; // Today's usage in KB
|
||||
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)
|
||||
// - PA05-22 (/mvno/eachQuota/): KB units (string recommended by spec)
|
||||
quota: number | string;
|
||||
quotaCode?: string; // Campaign code
|
||||
expire?: string; // YYYYMMDD format
|
||||
quotaCode?: string; // Campaign code
|
||||
expire?: string; // YYYYMMDD format
|
||||
// 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
|
||||
runTime?: string; // YYYYMMDD or YYYYMMDDhhmmss
|
||||
runTime?: string; // YYYYMMDD or YYYYMMDDhhmmss
|
||||
}
|
||||
|
||||
export interface FreebititTopUpResponse {
|
||||
@ -128,12 +128,12 @@ export interface FreebititAddSpecRequest {
|
||||
account: string;
|
||||
kind?: string; // Required by PA04-04 (/master/addSpec/), e.g. 'MVNO'
|
||||
// Feature flags: 10 = enabled, 20 = disabled
|
||||
voiceMail?: '10' | '20';
|
||||
voicemail?: '10' | '20';
|
||||
callWaiting?: '10' | '20';
|
||||
callwaiting?: '10' | '20';
|
||||
worldWing?: '10' | '20';
|
||||
worldwing?: '10' | '20';
|
||||
voiceMail?: "10" | "20";
|
||||
voicemail?: "10" | "20";
|
||||
callWaiting?: "10" | "20";
|
||||
callwaiting?: "10" | "20";
|
||||
worldWing?: "10" | "20";
|
||||
worldwing?: "10" | "20";
|
||||
contractLine?: string; // '4G' or '5G'
|
||||
}
|
||||
|
||||
@ -148,8 +148,8 @@ export interface FreebititAddSpecResponse {
|
||||
export interface FreebititQuotaHistoryRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
fromDate: string; // YYYYMMDD
|
||||
toDate: string; // YYYYMMDD
|
||||
fromDate: string; // YYYYMMDD
|
||||
toDate: string; // YYYYMMDD
|
||||
}
|
||||
|
||||
export interface FreebititQuotaHistoryResponse {
|
||||
@ -173,8 +173,8 @@ export interface FreebititPlanChangeRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
planCode: string;
|
||||
globalip?: '0' | '1'; // 0=no IP, 1=assign global IP
|
||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
||||
globalip?: "0" | "1"; // 0=no IP, 1=assign global IP
|
||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
||||
}
|
||||
|
||||
export interface FreebititPlanChangeResponse {
|
||||
@ -190,7 +190,7 @@ export interface FreebititPlanChangeResponse {
|
||||
export interface FreebititCancelPlanRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
||||
}
|
||||
|
||||
export interface FreebititCancelPlanResponse {
|
||||
@ -219,7 +219,7 @@ export interface FreebititEsimAddAccountRequest {
|
||||
aladinOperated?: string;
|
||||
account: string;
|
||||
eid: string;
|
||||
addKind: 'N' | 'R'; // N = new, R = reissue
|
||||
addKind: "N" | "R"; // N = new, R = reissue
|
||||
createType?: string;
|
||||
simKind?: string;
|
||||
planCode?: string;
|
||||
@ -244,13 +244,13 @@ export interface FreebititEsimAccountActivationRequest {
|
||||
aladinOperated: string; // '10' issue, '20' no-issue
|
||||
masterAccount?: string;
|
||||
masterPassword?: string;
|
||||
createType: 'new' | 'reissue' | 'exchange' | string;
|
||||
createType: "new" | "reissue" | "exchange" | string;
|
||||
eid?: string; // required for reissue/exchange per business rules
|
||||
account: string; // MSISDN
|
||||
simkind: 'esim' | string;
|
||||
simkind: "esim" | string;
|
||||
repAccount?: string;
|
||||
size?: string;
|
||||
addKind?: 'N' | 'R' | string; // e.g., 'R' for reissue
|
||||
addKind?: "N" | "R" | string; // e.g., 'R' for reissue
|
||||
oldEid?: string;
|
||||
oldProductNumber?: string;
|
||||
mnp?: {
|
||||
@ -285,9 +285,9 @@ export interface SimDetails {
|
||||
imsi?: string;
|
||||
eid?: string;
|
||||
planCode: string;
|
||||
status: 'active' | 'suspended' | 'cancelled' | 'pending';
|
||||
simType: 'physical' | 'esim';
|
||||
size: 'standard' | 'nano' | 'micro' | 'esim';
|
||||
status: "active" | "suspended" | "cancelled" | "pending";
|
||||
simType: "physical" | "esim";
|
||||
size: "standard" | "nano" | "micro" | "esim";
|
||||
hasVoice: boolean;
|
||||
hasSms: boolean;
|
||||
remainingQuotaKb: number;
|
||||
|
||||
@ -128,7 +128,9 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
||||
const event = payloadData as Record<string, unknown>;
|
||||
const payload = ((): Record<string, unknown> | undefined => {
|
||||
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
|
||||
|
||||
@ -449,7 +449,9 @@ export class WhmcsConnectionService {
|
||||
/**
|
||||
* Add a manual payment to an invoice
|
||||
*/
|
||||
async addInvoicePayment(params: WhmcsAddInvoicePaymentParams): Promise<WhmcsAddInvoicePaymentResponse> {
|
||||
async addInvoicePayment(
|
||||
params: WhmcsAddInvoicePaymentParams
|
||||
): Promise<WhmcsAddInvoicePaymentResponse> {
|
||||
return this.makeRequest("AddInvoicePayment", params);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
WhmcsUpdateInvoiceParams,
|
||||
WhmcsUpdateInvoiceResponse,
|
||||
WhmcsCapturePaymentParams,
|
||||
WhmcsCapturePaymentResponse
|
||||
WhmcsCapturePaymentResponse,
|
||||
} from "../types/whmcs-api.types";
|
||||
|
||||
export interface InvoiceFilters {
|
||||
@ -251,8 +251,8 @@ export class WhmcsInvoiceService {
|
||||
}): Promise<{ id: number; number: string; total: number; status: string }> {
|
||||
try {
|
||||
const dueDateStr = params.dueDate
|
||||
? params.dueDate.toISOString().split('T')[0]
|
||||
: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; // 7 days from now
|
||||
? params.dueDate.toISOString().split("T")[0]
|
||||
: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; // 7 days from now
|
||||
|
||||
const whmcsParams: WhmcsCreateInvoiceParams = {
|
||||
userid: params.clientId,
|
||||
@ -297,7 +297,14 @@ export class WhmcsInvoiceService {
|
||||
*/
|
||||
async updateInvoice(params: {
|
||||
invoiceId: number;
|
||||
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending";
|
||||
status?:
|
||||
| "Draft"
|
||||
| "Unpaid"
|
||||
| "Paid"
|
||||
| "Cancelled"
|
||||
| "Refunded"
|
||||
| "Collections"
|
||||
| "Payment Pending";
|
||||
dueDate?: Date;
|
||||
notes?: string;
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
@ -305,7 +312,7 @@ export class WhmcsInvoiceService {
|
||||
const whmcsParams: WhmcsUpdateInvoiceParams = {
|
||||
invoiceid: params.invoiceId,
|
||||
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,
|
||||
};
|
||||
|
||||
@ -370,7 +377,9 @@ export class WhmcsInvoiceService {
|
||||
});
|
||||
|
||||
// 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 {
|
||||
success: false,
|
||||
@ -404,27 +413,39 @@ export class WhmcsInvoiceService {
|
||||
const errorLower = technicalError.toLowerCase();
|
||||
|
||||
// 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.";
|
||||
}
|
||||
|
||||
// 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.";
|
||||
}
|
||||
|
||||
// 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.";
|
||||
}
|
||||
|
||||
// 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.";
|
||||
}
|
||||
|
||||
// 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.";
|
||||
}
|
||||
|
||||
|
||||
@ -96,7 +96,9 @@ export class WhmcsDataTransformer {
|
||||
// - Product names often contain "Activation Fee" or "Setup"
|
||||
const nameLower = (whmcsProduct.name || whmcsProduct.productname || "").toLowerCase();
|
||||
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) {
|
||||
normalizedCycle = "One-time";
|
||||
|
||||
@ -362,7 +362,14 @@ export interface WhmcsPaymentGatewaysResponse {
|
||||
// CreateInvoice API Types
|
||||
export interface WhmcsCreateInvoiceParams {
|
||||
userid: number;
|
||||
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending";
|
||||
status?:
|
||||
| "Draft"
|
||||
| "Unpaid"
|
||||
| "Paid"
|
||||
| "Cancelled"
|
||||
| "Refunded"
|
||||
| "Collections"
|
||||
| "Payment Pending";
|
||||
sendnotification?: boolean;
|
||||
paymentmethod?: string;
|
||||
taxrate?: number;
|
||||
@ -390,7 +397,14 @@ export interface WhmcsCreateInvoiceResponse {
|
||||
// UpdateInvoice API Types
|
||||
export interface WhmcsUpdateInvoiceParams {
|
||||
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
|
||||
notes?: string;
|
||||
[key: string]: unknown;
|
||||
|
||||
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: {
|
||||
invoiceId: number;
|
||||
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending";
|
||||
status?:
|
||||
| "Draft"
|
||||
| "Unpaid"
|
||||
| "Paid"
|
||||
| "Cancelled"
|
||||
| "Refunded"
|
||||
| "Collections"
|
||||
| "Payment Pending";
|
||||
dueDate?: Date;
|
||||
notes?: string;
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
// Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors
|
||||
import { mkdirSync, existsSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { mkdirSync, existsSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const root = new URL('..', import.meta.url).pathname; // apps/portal
|
||||
const nextDir = join(root, '.next');
|
||||
const routesManifestPath = join(nextDir, 'routes-manifest.json');
|
||||
const root = new URL("..", import.meta.url).pathname; // apps/portal
|
||||
const nextDir = join(root, ".next");
|
||||
const routesManifestPath = join(nextDir, "routes-manifest.json");
|
||||
|
||||
try {
|
||||
mkdirSync(nextDir, { recursive: true });
|
||||
@ -13,17 +13,15 @@ try {
|
||||
const minimalManifest = {
|
||||
version: 5,
|
||||
pages404: true,
|
||||
basePath: '',
|
||||
basePath: "",
|
||||
redirects: [],
|
||||
rewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
|
||||
headers: [],
|
||||
};
|
||||
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) {
|
||||
// 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>
|
||||
</h1>
|
||||
<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
|
||||
solution is personalized based on your location and account eligibility.
|
||||
Discover high-speed internet, wide range of mobile data options, and secure VPN
|
||||
services. Each solution is personalized based on your location and account eligibility.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -273,12 +273,16 @@ export default function SimPlansPage() {
|
||||
: "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
|
||||
{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 ${
|
||||
activeTab === "data-voice" ? "scale-110 bg-blue-200" : ""
|
||||
}`}>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
@ -291,12 +295,16 @@ export default function SimPlansPage() {
|
||||
: "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
|
||||
{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 ${
|
||||
activeTab === "data-only" ? "scale-110 bg-purple-200" : ""
|
||||
}`}>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
@ -309,12 +317,16 @@ export default function SimPlansPage() {
|
||||
: "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
|
||||
{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 ${
|
||||
activeTab === "voice-only" ? "scale-110 bg-orange-200" : ""
|
||||
}`}>
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
)}
|
||||
@ -325,11 +337,13 @@ export default function SimPlansPage() {
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[400px] relative">
|
||||
<div className={`transition-all duration-500 ease-in-out ${
|
||||
activeTab === "data-voice"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||
}`}>
|
||||
<div
|
||||
className={`transition-all duration-500 ease-in-out ${
|
||||
activeTab === "data-voice"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{activeTab === "data-voice" && (
|
||||
<PlanTypeSection
|
||||
title="Data + SMS/Voice Plans"
|
||||
@ -341,11 +355,13 @@ export default function SimPlansPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`transition-all duration-500 ease-in-out ${
|
||||
activeTab === "data-only"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||
}`}>
|
||||
<div
|
||||
className={`transition-all duration-500 ease-in-out ${
|
||||
activeTab === "data-only"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{activeTab === "data-only" && (
|
||||
<PlanTypeSection
|
||||
title="Data Only Plans"
|
||||
@ -357,11 +373,13 @@ export default function SimPlansPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`transition-all duration-500 ease-in-out ${
|
||||
activeTab === "voice-only"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||
}`}>
|
||||
<div
|
||||
className={`transition-all duration-500 ease-in-out ${
|
||||
activeTab === "voice-only"
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{activeTab === "voice-only" && (
|
||||
<PlanTypeSection
|
||||
title="Voice Only Plans"
|
||||
@ -437,29 +455,46 @@ export default function SimPlansPage() {
|
||||
<div className="space-y-3">
|
||||
<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 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 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 className="space-y-3">
|
||||
<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 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 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>
|
||||
|
||||
@ -3,7 +3,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
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 { StatusPill } from "@/components/ui/status-pill";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
@ -190,8 +204,8 @@ export default function OrderStatusPage() {
|
||||
Order Submitted Successfully!
|
||||
</h3>
|
||||
<p className="text-green-800 mb-3">
|
||||
Your order has been created and submitted for processing. We will notify you as
|
||||
soon as it's approved and ready for activation.
|
||||
Your order has been created and submitted for processing. We will notify you as soon
|
||||
as it's approved and ready for activation.
|
||||
</p>
|
||||
<div className="text-sm text-green-700">
|
||||
<p className="mb-1">
|
||||
@ -210,7 +224,7 @@ export default function OrderStatusPage() {
|
||||
)}
|
||||
|
||||
{/* Status Section - Moved to top */}
|
||||
{data && (
|
||||
{data &&
|
||||
(() => {
|
||||
const statusInfo = getDetailedStatusInfo(
|
||||
data.status,
|
||||
@ -230,9 +244,7 @@ export default function OrderStatusPage() {
|
||||
return (
|
||||
<SubCard
|
||||
className="mb-9"
|
||||
header={
|
||||
<h3 className="text-xl font-bold text-gray-900">Status</h3>
|
||||
}
|
||||
header={<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="text-gray-700 text-lg sm:text-xl">{statusInfo.description}</div>
|
||||
@ -262,15 +274,16 @@ export default function OrderStatusPage() {
|
||||
)}
|
||||
</SubCard>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
})()}
|
||||
|
||||
{/* Combined Service Overview and Products */}
|
||||
{data && (
|
||||
<div className="bg-white border rounded-2xl p-4 sm:p-8 mb-8">
|
||||
{/* Service Header */}
|
||||
<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">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2 flex items-center">
|
||||
{data.orderType} Service
|
||||
@ -349,111 +362,116 @@ export default function OrderStatusPage() {
|
||||
return 0;
|
||||
})
|
||||
.map(item => {
|
||||
// Use the actual Item_Class__c values from Salesforce documentation
|
||||
const itemClass = item.product.itemClass;
|
||||
// Use the actual Item_Class__c values from Salesforce documentation
|
||||
const itemClass = item.product.itemClass;
|
||||
|
||||
// Get appropriate icon and color based on item type and billing cycle
|
||||
const getItemTypeInfo = () => {
|
||||
const isMonthly = item.product.billingCycle === "Monthly";
|
||||
const isService = itemClass === "Service";
|
||||
const isInstallation = itemClass === "Installation";
|
||||
// Get appropriate icon and color based on item type and billing cycle
|
||||
const getItemTypeInfo = () => {
|
||||
const isMonthly = item.product.billingCycle === "Monthly";
|
||||
const isService = itemClass === "Service";
|
||||
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",
|
||||
};
|
||||
}
|
||||
};
|
||||
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();
|
||||
const typeInfo = getItemTypeInfo();
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate flex-1 min-w-0">
|
||||
{item.product.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor}`}
|
||||
>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
return (
|
||||
<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 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}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate flex-1 min-w-0">
|
||||
{item.product.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor}`}
|
||||
>
|
||||
{typeInfo.label}
|
||||
</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 className="text-left sm:text-right ml-0 sm:ml-3 mt-2 sm:mt-0 flex-shrink-0 sm:w-32">
|
||||
{item.totalPrice && (
|
||||
<div className="font-semibold text-gray-900 tabular-nums">
|
||||
¥{item.totalPrice.toLocaleString()}
|
||||
<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 && (
|
||||
<div className="font-semibold text-gray-900 tabular-nums">
|
||||
¥{item.totalPrice.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">
|
||||
{item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">
|
||||
{item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Additional fees warning */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-600 flex-shrink-0" />
|
||||
<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">
|
||||
Weekend installation (+¥3,000), express setup, or special configuration
|
||||
charges may be added. We will contact you before applying any additional
|
||||
@ -468,7 +486,6 @@ export default function OrderStatusPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Support Contact */}
|
||||
<SubCard title="Need Help?">
|
||||
<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 { useRouter, useSearchParams } from "next/navigation";
|
||||
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 { authenticatedApi } from "@/lib/api";
|
||||
|
||||
|
||||
@ -42,9 +42,9 @@ export default function SubscriptionDetailPage() {
|
||||
// Control what sections to show based on URL hash
|
||||
useEffect(() => {
|
||||
const updateVisibility = () => {
|
||||
const hash = typeof window !== 'undefined' ? window.location.hash : '';
|
||||
const service = (searchParams.get('service') || '').toLowerCase();
|
||||
const isSimContext = hash.includes('sim-management') || service === 'sim';
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
const service = (searchParams.get("service") || "").toLowerCase();
|
||||
const isSimContext = hash.includes("sim-management") || service === "sim";
|
||||
|
||||
if (isSimContext) {
|
||||
// Show only SIM management, hide invoices
|
||||
@ -57,9 +57,9 @@ export default function SubscriptionDetailPage() {
|
||||
}
|
||||
};
|
||||
updateVisibility();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('hashchange', updateVisibility);
|
||||
return () => window.removeEventListener('hashchange', updateVisibility);
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("hashchange", updateVisibility);
|
||||
return () => window.removeEventListener("hashchange", updateVisibility);
|
||||
}
|
||||
return;
|
||||
}, [searchParams]);
|
||||
@ -221,7 +221,6 @@ export default function SubscriptionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -279,21 +278,23 @@ export default function SubscriptionDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* 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="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>
|
||||
<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 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
|
||||
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 ${
|
||||
showSimManagement
|
||||
? 'bg-white text-blue-600 shadow-md hover:shadow-lg'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'
|
||||
? "bg-white text-blue-600 shadow-md hover:shadow-lg"
|
||||
: "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<ServerIcon className="h-4 w-4 inline mr-2" />
|
||||
@ -303,8 +304,8 @@ export default function SubscriptionDetailPage() {
|
||||
href={`/subscriptions/${subscriptionId}`}
|
||||
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[120px] text-center ${
|
||||
showInvoices
|
||||
? 'bg-white text-blue-600 shadow-md hover:shadow-lg'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-200'
|
||||
? "bg-white text-blue-600 shadow-md hover:shadow-lg"
|
||||
: "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<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 */}
|
||||
{showSimManagement && subscription.productName.toLowerCase().includes('sim') && (
|
||||
{showSimManagement && subscription.productName.toLowerCase().includes("sim") && (
|
||||
<SimManagementSection subscriptionId={subscriptionId} />
|
||||
)}
|
||||
|
||||
{/* Related Invoices (hidden when viewing SIM management directly) */}
|
||||
{showInvoices && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<DocumentTextIcon className="h-6 w-6 text-blue-600 mr-2" />
|
||||
<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 className="bg-white shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<DocumentTextIcon className="h-6 w-6 text-blue-600 mr-2" />
|
||||
<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>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<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"
|
||||
}`}
|
||||
{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)}`}
|
||||
>
|
||||
{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>
|
||||
{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>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<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>
|
||||
|
||||
@ -41,8 +41,8 @@ export default function SimCancelPage() {
|
||||
<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>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Cancel SIM: Permanently cancel your SIM service. This action cannot be
|
||||
undone and will terminate your service immediately.
|
||||
Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will
|
||||
terminate your service immediately.
|
||||
</p>
|
||||
|
||||
{message && (
|
||||
|
||||
@ -7,7 +7,7 @@ import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
|
||||
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> = {
|
||||
PASI_5G: "5GB",
|
||||
PASI_10G: "10GB",
|
||||
@ -24,7 +24,10 @@ export default function SimChangePlanPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
@ -51,32 +54,62 @@ export default function SimChangePlanPage() {
|
||||
<DashboardLayout>
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<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 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>
|
||||
<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>
|
||||
{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>}
|
||||
<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>
|
||||
{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">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">Choose a plan</option>
|
||||
{options.map(code => (
|
||||
<option key={code} value={code}>{PLAN_LABELS[code]}</option>
|
||||
<option key={code} value={code}>
|
||||
{PLAN_LABELS[code]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</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() {
|
||||
const params = useParams();
|
||||
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 [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -33,7 +33,7 @@ export default function SimTopUpPage() {
|
||||
e.preventDefault();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ export default function SimTopUpPage() {
|
||||
});
|
||||
setMessage(`Successfully topped up ${gbAmount} GB for ¥${calculateCost().toLocaleString()}`);
|
||||
} 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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -57,12 +57,19 @@ export default function SimTopUpPage() {
|
||||
<DashboardLayout>
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<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 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>
|
||||
<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 && (
|
||||
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
|
||||
@ -79,17 +86,15 @@ export default function SimTopUpPage() {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Amount Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Amount (GB)
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={gbAmount}
|
||||
onChange={(e) => setGbAmount(e.target.value)}
|
||||
onChange={e => setGbAmount(e.target.value)}
|
||||
placeholder="Enter amount in GB"
|
||||
min="1"
|
||||
max="50"
|
||||
max="50"
|
||||
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"
|
||||
/>
|
||||
@ -97,9 +102,9 @@ export default function SimTopUpPage() {
|
||||
<span className="text-gray-500 text-sm">GB</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cost Display */}
|
||||
@ -107,19 +112,15 @@ export default function SimTopUpPage() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-blue-900">
|
||||
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">
|
||||
= {getCurrentAmountMb()} MB
|
||||
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">= {getCurrentAmountMb()} MB</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-blue-900">
|
||||
¥{calculateCost().toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">
|
||||
(1GB = ¥500)
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -128,12 +129,20 @@ export default function SimTopUpPage() {
|
||||
{!isValidAmount() && gbAmount && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="flex items-center">
|
||||
<svg 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
|
||||
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>
|
||||
<p className="text-sm text-red-800">
|
||||
Amount must be a whole number between 1 GB and 50 GB
|
||||
</p>
|
||||
<p className="text-sm text-red-800">
|
||||
Amount must be a whole number between 1 GB and 50 GB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -145,7 +154,7 @@ export default function SimTopUpPage() {
|
||||
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"
|
||||
>
|
||||
{loading ? 'Processing...' : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||
</button>
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||
|
||||
@ -129,10 +129,10 @@ export default function SubscriptionsPage() {
|
||||
key: "cycle",
|
||||
header: "Billing Cycle",
|
||||
render: (subscription: Subscription) => {
|
||||
const name = (subscription.productName || '').toLowerCase();
|
||||
const name = (subscription.productName || "").toLowerCase();
|
||||
const looksLikeActivation =
|
||||
name.includes('activation fee') || name.includes('activation') || name.includes('setup');
|
||||
const displayCycle = looksLikeActivation ? 'One-time' : subscription.cycle;
|
||||
name.includes("activation fee") || name.includes("activation") || name.includes("setup");
|
||||
const displayCycle = looksLikeActivation ? "One-time" : subscription.cycle;
|
||||
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 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
|
||||
? pathname === item.href
|
||||
: false;
|
||||
|
||||
@ -22,10 +22,7 @@ export function ServiceManagementSection({
|
||||
subscriptionId,
|
||||
productName,
|
||||
}: ServiceManagementSectionProps) {
|
||||
const isSimService = useMemo(
|
||||
() => productName?.toLowerCase().includes("sim"),
|
||||
[productName]
|
||||
);
|
||||
const isSimService = useMemo(() => productName?.toLowerCase().includes("sim"), [productName]);
|
||||
|
||||
const [selectedService, setSelectedService] = useState<ServiceKey>(
|
||||
isSimService ? "SIM" : "INTERNET"
|
||||
@ -59,7 +56,7 @@ export function ServiceManagementSection({
|
||||
id="service-selector"
|
||||
className="block w-48 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
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="INTERNET">Internet (coming soon)</option>
|
||||
@ -99,12 +96,8 @@ export function ServiceManagementSection({
|
||||
) : (
|
||||
<div className="bg-white shadow rounded-lg p-6 text-center">
|
||||
<DevicePhoneMobileIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h4 className="mt-2 text-sm font-medium text-gray-900">
|
||||
SIM management not available
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
This subscription is not a SIM service.
|
||||
</p>
|
||||
<h4 className="mt-2 text-sm font-medium text-gray-900">SIM management not available</h4>
|
||||
<p className="mt-1 text-sm text-gray-500">This subscription is not a SIM service.</p>
|
||||
</div>
|
||||
)
|
||||
) : selectedService === "INTERNET" ? (
|
||||
|
||||
@ -1 +1 @@
|
||||
export { ServiceManagementSection } from './components/ServiceManagementSection';
|
||||
export { ServiceManagementSection } from "./components/ServiceManagementSection";
|
||||
|
||||
@ -12,9 +12,15 @@ interface ChangePlanModalProps {
|
||||
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;
|
||||
type PlanCode = typeof PLAN_CODES[number];
|
||||
type PlanCode = (typeof PLAN_CODES)[number];
|
||||
const PLAN_LABELS: Record<PlanCode, string> = {
|
||||
PASI_5G: "5GB",
|
||||
PASI_10G: "10GB",
|
||||
@ -22,7 +28,9 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
|
||||
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 [loading, setLoading] = useState(false);
|
||||
@ -48,9 +56,14 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
|
||||
return (
|
||||
<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="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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
@ -63,18 +76,25 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<option value="">Choose a plan</option>
|
||||
{allowedPlans.map(code => (
|
||||
<option key={code} value={code}>{PLAN_LABELS[code]}</option>
|
||||
<option key={code} value={code}>
|
||||
{PLAN_LABELS[code]}
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import React from "react";
|
||||
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export interface SimUsage {
|
||||
account: string;
|
||||
@ -26,7 +23,13 @@ interface DataUsageChartProps {
|
||||
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) => {
|
||||
if (usageMb >= 1000) {
|
||||
return `${(usageMb / 1000).toFixed(1)} GB`;
|
||||
@ -35,22 +38,22 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
||||
};
|
||||
|
||||
const getUsageColor = (percentage: number) => {
|
||||
if (percentage >= 90) return 'bg-red-500';
|
||||
if (percentage >= 75) return 'bg-yellow-500';
|
||||
if (percentage >= 50) return 'bg-orange-500';
|
||||
return 'bg-green-500';
|
||||
if (percentage >= 90) return "bg-red-500";
|
||||
if (percentage >= 75) return "bg-yellow-500";
|
||||
if (percentage >= 50) return "bg-orange-500";
|
||||
return "bg-green-500";
|
||||
};
|
||||
|
||||
const getUsageTextColor = (percentage: number) => {
|
||||
if (percentage >= 90) return 'text-red-600';
|
||||
if (percentage >= 75) return 'text-yellow-600';
|
||||
if (percentage >= 50) return 'text-orange-600';
|
||||
return 'text-green-600';
|
||||
if (percentage >= 90) return "text-red-600";
|
||||
if (percentage >= 75) return "text-yellow-600";
|
||||
if (percentage >= 50) return "text-orange-600";
|
||||
return "text-green-600";
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
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="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>
|
||||
@ -66,7 +69,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<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>
|
||||
@ -77,14 +80,17 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
||||
}
|
||||
|
||||
// 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 usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0;
|
||||
|
||||
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 */}
|
||||
<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="bg-blue-50 rounded-xl p-2 mr-4">
|
||||
<ChartBarIcon className="h-6 w-6 text-blue-600" />
|
||||
@ -97,7 +103,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
|
||||
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||
{/* Current Usage Overview */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
@ -135,8 +141,18 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
||||
<div className="text-sm font-medium text-blue-700 mt-1">Used today</div>
|
||||
</div>
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
<svg
|
||||
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>
|
||||
</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>
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4m16 0l-4 4m4-4l-4-4" />
|
||||
<svg
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@ -171,9 +197,9 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{new Date(day.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
{new Date(day.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center space-x-3">
|
||||
@ -216,7 +242,8 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-red-800">High Usage Warning</h4>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -230,7 +257,8 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-yellow-800">Usage Notice</h4>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
PlusIcon,
|
||||
ArrowPathIcon,
|
||||
XMarkIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { TopUpModal } from './TopUpModal';
|
||||
import { ChangePlanModal } from './ChangePlanModal';
|
||||
import { authenticatedApi } from '@/lib/api';
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { TopUpModal } from "./TopUpModal";
|
||||
import { ChangePlanModal } from "./ChangePlanModal";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
|
||||
interface SimActionsProps {
|
||||
subscriptionId: number;
|
||||
simType: 'physical' | 'esim';
|
||||
simType: "physical" | "esim";
|
||||
status: string;
|
||||
onTopUpSuccess?: () => void;
|
||||
onPlanChangeSuccess?: () => void;
|
||||
@ -34,7 +34,7 @@ export function SimActions({
|
||||
onCancelSuccess,
|
||||
onReissueSuccess,
|
||||
embedded = false,
|
||||
currentPlanCode
|
||||
currentPlanCode,
|
||||
}: SimActionsProps) {
|
||||
const router = useRouter();
|
||||
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||
@ -45,43 +45,43 @@ export function SimActions({
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showChangePlanModal, setShowChangePlanModal] = useState(false);
|
||||
const [activeInfo, setActiveInfo] = useState<
|
||||
'topup' | 'reissue' | 'cancel' | 'changePlan' | null
|
||||
"topup" | "reissue" | "cancel" | "changePlan" | null
|
||||
>(null);
|
||||
|
||||
const isActive = status === 'active';
|
||||
const isActive = status === "active";
|
||||
const canTopUp = isActive;
|
||||
const canReissue = isActive && simType === 'esim';
|
||||
const canReissue = isActive && simType === "esim";
|
||||
const canCancel = isActive;
|
||||
|
||||
const handleReissueEsim = async () => {
|
||||
setLoading('reissue');
|
||||
setLoading("reissue");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);
|
||||
|
||||
setSuccess('eSIM profile reissued successfully');
|
||||
setSuccess("eSIM profile reissued successfully");
|
||||
setShowReissueConfirm(false);
|
||||
onReissueSuccess?.();
|
||||
} 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 {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSim = async () => {
|
||||
setLoading('cancel');
|
||||
setLoading("cancel");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
||||
|
||||
setSuccess('SIM service cancelled successfully');
|
||||
setSuccess("SIM service cancelled successfully");
|
||||
setShowCancelConfirm(false);
|
||||
onCancelSuccess?.();
|
||||
} 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 {
|
||||
setLoading(null);
|
||||
}
|
||||
@ -100,13 +100,26 @@ export function SimActions({
|
||||
}, [success, error]);
|
||||
|
||||
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 */}
|
||||
<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="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">
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
@ -117,7 +130,7 @@ export function SimActions({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}>
|
||||
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||
{/* Status Messages */}
|
||||
{success && (
|
||||
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
@ -149,11 +162,11 @@ export function SimActions({
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo('topup');
|
||||
setActiveInfo("topup");
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
||||
} catch {
|
||||
@ -163,23 +176,23 @@ export function SimActions({
|
||||
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 ${
|
||||
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-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="bg-blue-100 rounded-lg p-1 mr-3">
|
||||
<PlusIcon className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<span>{loading === 'topup' ? 'Processing...' : 'Top Up Data'}</span>
|
||||
<span>{loading === "topup" ? "Processing..." : "Top Up Data"}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Reissue eSIM (only for eSIMs) */}
|
||||
{simType === 'esim' && (
|
||||
{simType === "esim" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo('reissue');
|
||||
setActiveInfo("reissue");
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
||||
} catch {
|
||||
@ -189,15 +202,15 @@ export function SimActions({
|
||||
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 ${
|
||||
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-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="bg-green-100 rounded-lg p-1 mr-3">
|
||||
<ArrowPathIcon className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<span>{loading === 'reissue' ? 'Processing...' : 'Reissue eSIM'}</span>
|
||||
<span>{loading === "reissue" ? "Processing..." : "Reissue eSIM"}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@ -205,7 +218,7 @@ export function SimActions({
|
||||
{/* Cancel SIM - Destructive Action */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo('cancel');
|
||||
setActiveInfo("cancel");
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
} catch {
|
||||
@ -216,22 +229,22 @@ export function SimActions({
|
||||
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 ${
|
||||
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-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed'
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="bg-red-100 rounded-lg p-1 mr-3">
|
||||
<XMarkIcon className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<span>{loading === 'cancel' ? 'Processing...' : 'Cancel SIM'}</span>
|
||||
<span>{loading === "cancel" ? "Processing..." : "Cancel SIM"}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Change Plan - Secondary Action */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo('changePlan');
|
||||
setActiveInfo("changePlan");
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||
} catch {
|
||||
@ -241,14 +254,24 @@ export function SimActions({
|
||||
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 ${
|
||||
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-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed'
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
<svg
|
||||
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>
|
||||
</div>
|
||||
<span>Change Plan</span>
|
||||
@ -259,37 +282,54 @@ export function SimActions({
|
||||
{/* Action Description (contextual) */}
|
||||
{activeInfo && (
|
||||
<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">
|
||||
<PlusIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<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>
|
||||
)}
|
||||
{activeInfo === 'reissue' && (
|
||||
{activeInfo === "reissue" && (
|
||||
<div className="flex items-start">
|
||||
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<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>
|
||||
)}
|
||||
{activeInfo === 'cancel' && (
|
||||
{activeInfo === "cancel" && (
|
||||
<div className="flex items-start">
|
||||
<XMarkIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<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>
|
||||
)}
|
||||
{activeInfo === 'changePlan' && (
|
||||
{activeInfo === "changePlan" && (
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
<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>
|
||||
)}
|
||||
@ -301,13 +341,16 @@ export function SimActions({
|
||||
{showTopUpModal && (
|
||||
<TopUpModal
|
||||
subscriptionId={subscriptionId}
|
||||
onClose={() => { setShowTopUpModal(false); setActiveInfo(null); }}
|
||||
onClose={() => {
|
||||
setShowTopUpModal(false);
|
||||
setActiveInfo(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowTopUpModal(false);
|
||||
setSuccess('Data top-up completed successfully');
|
||||
setSuccess("Data top-up completed successfully");
|
||||
onTopUpSuccess?.();
|
||||
}}
|
||||
onError={(message) => setError(message)}
|
||||
onError={message => setError(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -316,13 +359,16 @@ export function SimActions({
|
||||
<ChangePlanModal
|
||||
subscriptionId={subscriptionId}
|
||||
currentPlanCode={currentPlanCode}
|
||||
onClose={() => { setShowChangePlanModal(false); setActiveInfo(null); }}
|
||||
onClose={() => {
|
||||
setShowChangePlanModal(false);
|
||||
setActiveInfo(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowChangePlanModal(false);
|
||||
setSuccess('SIM plan change submitted successfully');
|
||||
setSuccess("SIM plan change submitted successfully");
|
||||
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" />
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -351,15 +400,18 @@ export function SimActions({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{loading === 'reissue' ? 'Processing...' : 'Reissue eSIM'}
|
||||
{loading === "reissue" ? "Processing..." : "Reissue eSIM"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowReissueConfirm(false); setActiveInfo(null); }}
|
||||
disabled={loading === 'reissue'}
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
Back
|
||||
@ -382,10 +434,13 @@ export function SimActions({
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -395,15 +450,18 @@ export function SimActions({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{loading === 'cancel' ? 'Processing...' : 'Cancel SIM'}
|
||||
{loading === "cancel" ? "Processing..." : "Cancel SIM"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowCancelConfirm(false); setActiveInfo(null); }}
|
||||
disabled={loading === 'cancel'}
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
Back
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
WifiIcon,
|
||||
@ -8,8 +8,8 @@ import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
export interface SimDetails {
|
||||
account: string;
|
||||
@ -18,9 +18,9 @@ export interface SimDetails {
|
||||
imsi?: string;
|
||||
eid?: string;
|
||||
planCode: string;
|
||||
status: 'active' | 'suspended' | 'cancelled' | 'pending';
|
||||
simType: 'physical' | 'esim';
|
||||
size: 'standard' | 'nano' | 'micro' | 'esim';
|
||||
status: "active" | "suspended" | "cancelled" | "pending";
|
||||
simType: "physical" | "esim";
|
||||
size: "standard" | "nano" | "micro" | "esim";
|
||||
hasVoice: boolean;
|
||||
hasSms: boolean;
|
||||
remainingQuotaKb: number;
|
||||
@ -46,25 +46,31 @@ interface SimDetailsCardProps {
|
||||
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 map: Record<string, string> = {
|
||||
PASI_5G: '5GB Plan',
|
||||
PASI_10G: '10GB Plan',
|
||||
PASI_25G: '25GB Plan',
|
||||
PASI_50G: '50GB Plan',
|
||||
PASI_5G: "5GB Plan",
|
||||
PASI_10G: "10GB Plan",
|
||||
PASI_25G: "25GB Plan",
|
||||
PASI_50G: "50GB Plan",
|
||||
};
|
||||
return (code && map[code]) || code || '—';
|
||||
return (code && map[code]) || code || "—";
|
||||
};
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case "active":
|
||||
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
||||
case 'suspended':
|
||||
case "suspended":
|
||||
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
||||
case 'cancelled':
|
||||
case "cancelled":
|
||||
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
||||
case 'pending':
|
||||
case "pending":
|
||||
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
||||
default:
|
||||
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) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'suspended':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'pending':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case "active":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "suspended":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "cancelled":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "pending":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
@ -108,7 +114,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
||||
|
||||
if (isLoading) {
|
||||
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="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>
|
||||
@ -130,7 +138,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
||||
|
||||
if (error) {
|
||||
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="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" />
|
||||
@ -143,11 +153,13 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
||||
}
|
||||
|
||||
// Specialized, minimal eSIM details view
|
||||
if (simDetails.simType === 'esim') {
|
||||
if (simDetails.simType === "esim") {
|
||||
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 */}
|
||||
<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 items-center">
|
||||
<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>
|
||||
<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>
|
||||
<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)}
|
||||
</span>
|
||||
</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="space-y-6">
|
||||
<div>
|
||||
@ -174,15 +190,21 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
||||
</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Data Remaining</label>
|
||||
<p className="text-2xl font-bold text-green-600 mt-1">{formatQuota(simDetails.remainingQuotaMb)}</p>
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Data Remaining
|
||||
</label>
|
||||
<p className="text-2xl font-bold text-green-600 mt-1">
|
||||
{formatQuota(simDetails.remainingQuotaMb)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -195,26 +217,32 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
||||
<div className="space-y-3">
|
||||
<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 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
|
||||
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>
|
||||
</div>
|
||||
<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 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
|
||||
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>
|
||||
</div>
|
||||
<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 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
|
||||
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>
|
||||
</div>
|
||||
<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 font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800">
|
||||
{simDetails.networkType || '5G'}
|
||||
{simDetails.networkType || "5G"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -227,9 +255,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg'}`}>
|
||||
<div className={`${embedded ? "" : "bg-white shadow rounded-lg"}`}>
|
||||
{/* 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">
|
||||
<div className="text-2xl mr-3">
|
||||
@ -244,7 +272,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{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)}
|
||||
</span>
|
||||
</div>
|
||||
@ -252,7 +282,7 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* SIM Information */}
|
||||
<div>
|
||||
@ -265,7 +295,7 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
|
||||
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
|
||||
</div>
|
||||
|
||||
{simDetails.simType === 'physical' && (
|
||||
{simDetails.simType === "physical" && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">ICCID</label>
|
||||
<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>
|
||||
<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 className="flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<SignalIcon className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<span className={`text-sm ${simDetails.hasVoice ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
Voice {simDetails.hasVoice ? 'Enabled' : 'Disabled'}
|
||||
<SignalIcon
|
||||
className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? "text-green-500" : "text-gray-400"}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm ${simDetails.hasVoice ? "text-green-600" : "text-gray-500"}`}
|
||||
>
|
||||
Voice {simDetails.hasVoice ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DevicePhoneMobileIcon className={`h-4 w-4 mr-1 ${simDetails.hasSms ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<span className={`text-sm ${simDetails.hasSms ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
SMS {simDetails.hasSms ? 'Enabled' : 'Disabled'}
|
||||
<DevicePhoneMobileIcon
|
||||
className={`h-4 w-4 mr-1 ${simDetails.hasSms ? "text-green-500" : "text-gray-400"}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm ${simDetails.hasSms ? "text-green-600" : "text-gray-500"}`}
|
||||
>
|
||||
SMS {simDetails.hasSms ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -23,18 +23,21 @@ export function SimFeatureToggles({
|
||||
embedded = false,
|
||||
}: SimFeatureTogglesProps) {
|
||||
// Initial values
|
||||
const initial = useMemo(() => ({
|
||||
vm: !!voiceMailEnabled,
|
||||
cw: !!callWaitingEnabled,
|
||||
ir: !!internationalRoamingEnabled,
|
||||
nt: networkType === '5G' ? '5G' : '4G',
|
||||
}), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]);
|
||||
const initial = useMemo(
|
||||
() => ({
|
||||
vm: !!voiceMailEnabled,
|
||||
cw: !!callWaitingEnabled,
|
||||
ir: !!internationalRoamingEnabled,
|
||||
nt: networkType === "5G" ? "5G" : "4G",
|
||||
}),
|
||||
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
|
||||
);
|
||||
|
||||
// Working values
|
||||
const [vm, setVm] = useState(initial.vm);
|
||||
const [cw, setCw] = useState(initial.cw);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
@ -43,14 +46,14 @@ export function SimFeatureToggles({
|
||||
setVm(initial.vm);
|
||||
setCw(initial.cw);
|
||||
setIr(initial.ir);
|
||||
setNt(initial.nt as '4G' | '5G');
|
||||
setNt(initial.nt as "4G" | "5G");
|
||||
}, [initial.vm, initial.cw, initial.ir, initial.nt]);
|
||||
|
||||
const reset = () => {
|
||||
setVm(initial.vm);
|
||||
setCw(initial.cw);
|
||||
setIr(initial.ir);
|
||||
setNt(initial.nt as '4G' | '5G');
|
||||
setNt(initial.nt as "4G" | "5G");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
};
|
||||
@ -67,13 +70,16 @@ export function SimFeatureToggles({
|
||||
if (nt !== initial.nt) featurePayload.networkType = nt;
|
||||
|
||||
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?.();
|
||||
} catch (e: any) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to submit changes');
|
||||
setError(e instanceof Error ? e.message : "Failed to submit changes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
@ -82,18 +88,28 @@ export function SimFeatureToggles({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Service Options */}
|
||||
<div className={`${embedded ? '' : 'bg-white rounded-xl border border-gray-200 overflow-hidden'}`}>
|
||||
|
||||
<div className={`${embedded ? '' : 'p-6'} space-y-6`}>
|
||||
<div
|
||||
className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 overflow-hidden"}`}
|
||||
>
|
||||
<div className={`${embedded ? "" : "p-6"} space-y-6`}>
|
||||
{/* 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-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
@ -105,14 +121,14 @@ export function SimFeatureToggles({
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">Current: </span>
|
||||
<span className={`font-medium ${initial.vm ? 'text-green-600' : 'text-gray-600'}`}>
|
||||
{initial.vm ? 'Enabled' : 'Disabled'}
|
||||
<span className={`font-medium ${initial.vm ? "text-green-600" : "text-gray-600"}`}>
|
||||
{initial.vm ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-400">→</div>
|
||||
<select
|
||||
value={vm ? 'Enabled' : 'Disabled'}
|
||||
onChange={(e) => setVm(e.target.value === 'Enabled')}
|
||||
value={vm ? "Enabled" : "Disabled"}
|
||||
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"
|
||||
>
|
||||
<option>Disabled</option>
|
||||
@ -126,8 +142,18 @@ export function SimFeatureToggles({
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
@ -139,14 +165,14 @@ export function SimFeatureToggles({
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">Current: </span>
|
||||
<span className={`font-medium ${initial.cw ? 'text-green-600' : 'text-gray-600'}`}>
|
||||
{initial.cw ? 'Enabled' : 'Disabled'}
|
||||
<span className={`font-medium ${initial.cw ? "text-green-600" : "text-gray-600"}`}>
|
||||
{initial.cw ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-400">→</div>
|
||||
<select
|
||||
value={cw ? 'Enabled' : 'Disabled'}
|
||||
onChange={(e) => setCw(e.target.value === 'Enabled')}
|
||||
value={cw ? "Enabled" : "Disabled"}
|
||||
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"
|
||||
>
|
||||
<option>Disabled</option>
|
||||
@ -160,8 +186,18 @@ export function SimFeatureToggles({
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
@ -173,14 +209,14 @@ export function SimFeatureToggles({
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">Current: </span>
|
||||
<span className={`font-medium ${initial.ir ? 'text-green-600' : 'text-gray-600'}`}>
|
||||
{initial.ir ? 'Enabled' : 'Disabled'}
|
||||
<span className={`font-medium ${initial.ir ? "text-green-600" : "text-gray-600"}`}>
|
||||
{initial.ir ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-400">→</div>
|
||||
<select
|
||||
value={ir ? 'Enabled' : 'Disabled'}
|
||||
onChange={(e) => setIr(e.target.value === 'Enabled')}
|
||||
value={ir ? "Enabled" : "Disabled"}
|
||||
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"
|
||||
>
|
||||
<option>Disabled</option>
|
||||
@ -194,8 +230,18 @@ export function SimFeatureToggles({
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
<div>
|
||||
@ -212,7 +258,7 @@ export function SimFeatureToggles({
|
||||
<div className="text-gray-400">→</div>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="4G">4G</option>
|
||||
@ -224,19 +270,34 @@ export function SimFeatureToggles({
|
||||
</div>
|
||||
|
||||
{/* 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="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">
|
||||
<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
|
||||
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>
|
||||
<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">
|
||||
<li>Changes will take effect instantaneously (approx. 30min)</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>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>
|
||||
</div>
|
||||
</div>
|
||||
@ -245,8 +306,18 @@ export function SimFeatureToggles({
|
||||
{success && (
|
||||
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
<p className="text-sm font-medium text-green-800">{success}</p>
|
||||
</div>
|
||||
@ -256,8 +327,18 @@ export function SimFeatureToggles({
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
<p className="text-sm font-medium text-red-800">{error}</p>
|
||||
</div>
|
||||
@ -272,16 +353,36 @@ export function SimFeatureToggles({
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" 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
|
||||
className="animate-spin -ml-1 mr-3 h-4 w-4 text-white"
|
||||
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>
|
||||
Applying Changes...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { SimDetailsCard, type SimDetails } from './SimDetailsCard';
|
||||
import { DataUsageChart, type SimUsage } from './DataUsageChart';
|
||||
import { SimActions } from './SimActions';
|
||||
import { authenticatedApi } from '@/lib/api';
|
||||
import { SimFeatureToggles } from './SimFeatureToggles';
|
||||
ArrowPathIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { SimDetailsCard, type SimDetails } from "./SimDetailsCard";
|
||||
import { DataUsageChart, type SimUsage } from "./DataUsageChart";
|
||||
import { SimActions } from "./SimActions";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import { SimFeatureToggles } from "./SimFeatureToggles";
|
||||
|
||||
interface SimManagementSectionProps {
|
||||
subscriptionId: number;
|
||||
@ -39,9 +39,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
} catch (error: any) {
|
||||
if (error.status === 400) {
|
||||
// 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 {
|
||||
setError(error instanceof Error ? error.message : 'Failed to load SIM information');
|
||||
setError(error instanceof Error ? error.message : "Failed to load SIM information");
|
||||
}
|
||||
} finally {
|
||||
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">
|
||||
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
|
||||
</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>
|
||||
<button
|
||||
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="flex items-center mb-4">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
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 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>
|
||||
@ -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>
|
||||
SIM cancellation is permanent and cannot be undone
|
||||
</li>
|
||||
{simInfo.details.simType === 'esim' && (
|
||||
{simInfo.details.simType === "esim" && (
|
||||
<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>
|
||||
eSIM profile reissue will provide a new QR code for activation
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { authenticatedApi } from '@/lib/api';
|
||||
import React, { useState } from "react";
|
||||
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
|
||||
interface TopUpModalProps {
|
||||
subscriptionId: number;
|
||||
@ -16,7 +12,7 @@ interface 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 getCurrentAmountMb = () => {
|
||||
@ -38,7 +34,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
e.preventDefault();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -53,7 +49,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
|
||||
onSuccess();
|
||||
} 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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -94,14 +90,12 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Amount Input */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Amount (GB)
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
value={gbAmount}
|
||||
onChange={(e) => setGbAmount(e.target.value)}
|
||||
onChange={e => setGbAmount(e.target.value)}
|
||||
placeholder="Enter amount in GB"
|
||||
min="1"
|
||||
max="50"
|
||||
@ -122,19 +116,15 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-blue-900">
|
||||
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">
|
||||
= {getCurrentAmountMb()} MB
|
||||
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">= {getCurrentAmountMb()} MB</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-blue-900">
|
||||
¥{calculateCost().toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">
|
||||
(1GB = ¥500)
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -166,7 +156,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
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"
|
||||
>
|
||||
{loading ? 'Processing...' : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
export { SimManagementSection } from './components/SimManagementSection';
|
||||
export { SimDetailsCard } from './components/SimDetailsCard';
|
||||
export { DataUsageChart } from './components/DataUsageChart';
|
||||
export { SimActions } from './components/SimActions';
|
||||
export { TopUpModal } from './components/TopUpModal';
|
||||
export { SimFeatureToggles } from './components/SimFeatureToggles';
|
||||
export { SimManagementSection } from "./components/SimManagementSection";
|
||||
export { SimDetailsCard } from "./components/SimDetailsCard";
|
||||
export { DataUsageChart } from "./components/DataUsageChart";
|
||||
export { SimActions } from "./components/SimActions";
|
||||
export { TopUpModal } from "./components/TopUpModal";
|
||||
export { SimFeatureToggles } from "./components/SimFeatureToggles";
|
||||
|
||||
export type { SimDetails } from './components/SimDetailsCard';
|
||||
export type { SimUsage } from './components/DataUsageChart';
|
||||
export type { SimDetails } from "./components/SimDetailsCard";
|
||||
export type { SimUsage } from "./components/DataUsageChart";
|
||||
|
||||
@ -19,9 +19,7 @@ export function QueryProvider({ children }: QueryProviderProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{enableDevtools && ReactQueryDevtools ? (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
) : null}
|
||||
{enableDevtools && ReactQueryDevtools ? <ReactQueryDevtools initialIsOpen={false} /> : null}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user