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:
tema 2025-09-09 15:45:03 +09:00
parent 425ef83dba
commit 05817e8c67
38 changed files with 1632 additions and 1105 deletions

View File

@ -30,9 +30,12 @@ export class TokenBlacklistService {
const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d")); const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));
await this.redis.setex(`blacklist:${token}`, defaultTtl, "1"); await this.redis.setex(`blacklist:${token}`, defaultTtl, "1");
} catch (err) { } catch (err) {
this.logger.warn("Failed to write token to Redis blacklist; proceeding without persistence", { this.logger.warn(
error: err instanceof Error ? err.message : String(err), "Failed to write token to Redis blacklist; proceeding without persistence",
}); {
error: err instanceof Error ? err.message : String(err),
}
);
} }
} }
} }

View File

@ -45,9 +45,7 @@ export const envSchema = z.object({
// Salesforce Platform Events (Async Provisioning) // Salesforce Platform Events (Async Provisioning)
SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"), SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"),
SF_PROVISION_EVENT_CHANNEL: z SF_PROVISION_EVENT_CHANNEL: z.string().default("/event/Order_Fulfilment_Requested__e"),
.string()
.default("/event/Order_Fulfilment_Requested__e"),
SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"), SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"),
SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"), SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"),
SF_PUBSUB_NUM_REQUESTED: z.string().default("50"), SF_PUBSUB_NUM_REQUESTED: z.string().default("50"),

View File

@ -188,13 +188,11 @@ export function getSalesforceFieldMap(): SalesforceFieldMap {
// Billing address snapshot fields — single source of truth: Billing* fields on Order // Billing address snapshot fields — single source of truth: Billing* fields on Order
billing: { billing: {
street: process.env.ORDER_BILLING_STREET_FIELD || "BillingStreet", street: process.env.ORDER_BILLING_STREET_FIELD || "BillingStreet",
city: process.env.ORDER_BILLING_CITY_FIELD || "BillingCity", city: process.env.ORDER_BILLING_CITY_FIELD || "BillingCity",
state: process.env.ORDER_BILLING_STATE_FIELD || "BillingState", state: process.env.ORDER_BILLING_STATE_FIELD || "BillingState",
postalCode: process.env.ORDER_BILLING_POSTAL_CODE_FIELD || "BillingPostalCode", postalCode: process.env.ORDER_BILLING_POSTAL_CODE_FIELD || "BillingPostalCode",
country: process.env.ORDER_BILLING_COUNTRY_FIELD || "BillingCountry", country: process.env.ORDER_BILLING_COUNTRY_FIELD || "BillingCountry",
}, },
}, },
orderItem: { orderItem: {

View File

@ -1,12 +1,12 @@
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common'; import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common";
import { Logger } from 'nestjs-pino'; import { Logger } from "nestjs-pino";
import { FreebititService } from '../vendors/freebit/freebit.service'; import { FreebititService } from "../vendors/freebit/freebit.service";
import { WhmcsService } from '../vendors/whmcs/whmcs.service'; import { WhmcsService } from "../vendors/whmcs/whmcs.service";
import { MappingsService } from '../mappings/mappings.service'; import { MappingsService } from "../mappings/mappings.service";
import { SubscriptionsService } from './subscriptions.service'; import { SubscriptionsService } from "./subscriptions.service";
import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types'; import { SimDetails, SimUsage, SimTopUpHistory } from "../vendors/freebit/interfaces/freebit.types";
import { SimUsageStoreService } from './sim-usage-store.service'; import { SimUsageStoreService } from "./sim-usage-store.service";
import { getErrorMessage } from '../common/utils/error.util'; import { getErrorMessage } from "../common/utils/error.util";
export interface SimTopUpRequest { export interface SimTopUpRequest {
quotaMb: number; quotaMb: number;
@ -22,14 +22,14 @@ export interface SimCancelRequest {
export interface SimTopUpHistoryRequest { export interface SimTopUpHistoryRequest {
fromDate: string; // YYYYMMDD fromDate: string; // YYYYMMDD
toDate: string; // YYYYMMDD toDate: string; // YYYYMMDD
} }
export interface SimFeaturesUpdateRequest { export interface SimFeaturesUpdateRequest {
voiceMailEnabled?: boolean; voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean; callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean; internationalRoamingEnabled?: boolean;
networkType?: '4G' | '5G'; networkType?: "4G" | "5G";
} }
@Injectable() @Injectable()
@ -40,7 +40,7 @@ export class SimManagementService {
private readonly mappingsService: MappingsService, private readonly mappingsService: MappingsService,
private readonly subscriptionsService: SubscriptionsService, private readonly subscriptionsService: SubscriptionsService,
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly usageStore: SimUsageStoreService, private readonly usageStore: SimUsageStoreService
) {} ) {}
/** /**
@ -48,37 +48,43 @@ export class SimManagementService {
*/ */
async debugSimSubscription(userId: string, subscriptionId: number): Promise<any> { async debugSimSubscription(userId: string, subscriptionId: number): Promise<any> {
try { try {
const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId); const subscription = await this.subscriptionsService.getSubscriptionById(
userId,
subscriptionId
);
// Check for specific SIM data // Check for specific SIM data
const expectedSimNumber = '02000331144508'; const expectedSimNumber = "02000331144508";
const expectedEid = '89049032000001000000043598005455'; const expectedEid = "89049032000001000000043598005455";
const simNumberField = Object.entries(subscription.customFields || {}).find( const simNumberField = Object.entries(subscription.customFields || {}).find(
([key, value]) => value && value.toString().includes(expectedSimNumber) ([key, value]) => value && value.toString().includes(expectedSimNumber)
); );
const eidField = Object.entries(subscription.customFields || {}).find( const eidField = Object.entries(subscription.customFields || {}).find(
([key, value]) => value && value.toString().includes(expectedEid) ([key, value]) => value && value.toString().includes(expectedEid)
); );
return { return {
subscriptionId, subscriptionId,
productName: subscription.productName, productName: subscription.productName,
domain: subscription.domain, domain: subscription.domain,
orderNumber: subscription.orderNumber, orderNumber: subscription.orderNumber,
customFields: subscription.customFields, customFields: subscription.customFields,
isSimService: subscription.productName.toLowerCase().includes('sim') || isSimService:
subscription.groupName?.toLowerCase().includes('sim'), subscription.productName.toLowerCase().includes("sim") ||
subscription.groupName?.toLowerCase().includes("sim"),
groupName: subscription.groupName, groupName: subscription.groupName,
status: subscription.status, status: subscription.status,
// Specific SIM data checks // Specific SIM data checks
expectedSimNumber, expectedSimNumber,
expectedEid, expectedEid,
foundSimNumber: simNumberField ? { field: simNumberField[0], value: simNumberField[1] } : null, foundSimNumber: simNumberField
? { field: simNumberField[0], value: simNumberField[1] }
: null,
foundEid: eidField ? { field: eidField[0], value: eidField[1] } : null, foundEid: eidField ? { field: eidField[0], value: eidField[1] } : null,
allCustomFieldKeys: Object.keys(subscription.customFields || {}), allCustomFieldKeys: Object.keys(subscription.customFields || {}),
allCustomFieldValues: subscription.customFields allCustomFieldValues: subscription.customFields,
}; };
} catch (error) { } catch (error) {
this.logger.error(`Failed to debug subscription ${subscriptionId}`, { this.logger.error(`Failed to debug subscription ${subscriptionId}`, {
@ -91,44 +97,79 @@ export class SimManagementService {
/** /**
* Check if a subscription is a SIM service * Check if a subscription is a SIM service
*/ */
private async validateSimSubscription(userId: string, subscriptionId: number): Promise<{ account: string }> { private async validateSimSubscription(
userId: string,
subscriptionId: number
): Promise<{ account: string }> {
try { try {
// Get subscription details to verify it's a SIM service // Get subscription details to verify it's a SIM service
const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId); const subscription = await this.subscriptionsService.getSubscriptionById(
userId,
subscriptionId
);
// Check if this is a SIM service (you may need to adjust this logic based on your product naming) // Check if this is a SIM service (you may need to adjust this logic based on your product naming)
const isSimService = subscription.productName.toLowerCase().includes('sim') || const isSimService =
subscription.groupName?.toLowerCase().includes('sim'); subscription.productName.toLowerCase().includes("sim") ||
subscription.groupName?.toLowerCase().includes("sim");
if (!isSimService) { if (!isSimService) {
throw new BadRequestException('This subscription is not a SIM service'); throw new BadRequestException("This subscription is not a SIM service");
} }
// For SIM services, the account identifier (phone number) can be stored in multiple places // For SIM services, the account identifier (phone number) can be stored in multiple places
let account = ''; let account = "";
// 1. Try domain field first // 1. Try domain field first
if (subscription.domain && subscription.domain.trim()) { if (subscription.domain && subscription.domain.trim()) {
account = subscription.domain.trim(); account = subscription.domain.trim();
} }
// 2. If no domain, check custom fields for phone number/MSISDN // 2. If no domain, check custom fields for phone number/MSISDN
if (!account && subscription.customFields) { if (!account && subscription.customFields) {
// Common field names for SIM phone numbers in WHMCS // Common field names for SIM phone numbers in WHMCS
const phoneFields = [ const phoneFields = [
'phone', 'msisdn', 'phonenumber', 'phone_number', 'mobile', 'sim_phone', "phone",
'Phone Number', 'MSISDN', 'Phone', 'Mobile', 'SIM Phone', 'PhoneNumber', "msisdn",
'phone_number', 'mobile_number', 'sim_number', 'account_number', "phonenumber",
'Account Number', 'SIM Account', 'Phone Number (SIM)', 'Mobile Number', "phone_number",
"mobile",
"sim_phone",
"Phone Number",
"MSISDN",
"Phone",
"Mobile",
"SIM Phone",
"PhoneNumber",
"phone_number",
"mobile_number",
"sim_number",
"account_number",
"Account Number",
"SIM Account",
"Phone Number (SIM)",
"Mobile Number",
// Specific field names that might contain the SIM number // Specific field names that might contain the SIM number
'SIM Number', 'SIM_Number', 'sim_number', 'SIM_Phone_Number', "SIM Number",
'Phone_Number_SIM', 'Mobile_SIM_Number', 'SIM_Account_Number', "SIM_Number",
'ICCID', 'iccid', 'IMSI', 'imsi', 'EID', 'eid', "sim_number",
"SIM_Phone_Number",
"Phone_Number_SIM",
"Mobile_SIM_Number",
"SIM_Account_Number",
"ICCID",
"iccid",
"IMSI",
"imsi",
"EID",
"eid",
// Additional variations // Additional variations
'02000331144508', // Direct match for your specific SIM number "02000331144508", // Direct match for your specific SIM number
'SIM_Data', 'SIM_Info', 'SIM_Details' "SIM_Data",
"SIM_Info",
"SIM_Details",
]; ];
for (const fieldName of phoneFields) { for (const fieldName of phoneFields) {
if (subscription.customFields[fieldName]) { if (subscription.customFields[fieldName]) {
account = subscription.customFields[fieldName]; account = subscription.customFields[fieldName];
@ -136,35 +177,40 @@ export class SimManagementService {
userId, userId,
subscriptionId, subscriptionId,
fieldName, fieldName,
account account,
}); });
break; break;
} }
} }
// If still no account found, log all available custom fields for debugging // If still no account found, log all available custom fields for debugging
if (!account) { if (!account) {
this.logger.warn(`No SIM account found in custom fields for subscription ${subscriptionId}`, { this.logger.warn(
userId, `No SIM account found in custom fields for subscription ${subscriptionId}`,
subscriptionId, {
availableFields: Object.keys(subscription.customFields), userId,
customFields: subscription.customFields, subscriptionId,
searchedFields: phoneFields availableFields: Object.keys(subscription.customFields),
}); customFields: subscription.customFields,
searchedFields: phoneFields,
}
);
// Check if any field contains the expected SIM number // Check if any field contains the expected SIM number
const expectedSimNumber = '02000331144508'; const expectedSimNumber = "02000331144508";
const foundSimNumber = Object.entries(subscription.customFields || {}).find( const foundSimNumber = Object.entries(subscription.customFields || {}).find(
([key, value]) => value && value.toString().includes(expectedSimNumber) ([key, value]) => value && value.toString().includes(expectedSimNumber)
); );
if (foundSimNumber) { if (foundSimNumber) {
this.logger.log(`Found expected SIM number ${expectedSimNumber} in field '${foundSimNumber[0]}': ${foundSimNumber[1]}`); this.logger.log(
`Found expected SIM number ${expectedSimNumber} in field '${foundSimNumber[0]}': ${foundSimNumber[1]}`
);
account = foundSimNumber[1].toString(); account = foundSimNumber[1].toString();
} }
} }
} }
// 3. If still no account, check if subscription ID looks like a phone number // 3. If still no account, check if subscription ID looks like a phone number
if (!account && subscription.orderNumber) { if (!account && subscription.orderNumber) {
const orderNum = subscription.orderNumber.toString(); const orderNum = subscription.orderNumber.toString();
@ -172,25 +218,28 @@ export class SimManagementService {
account = orderNum; account = orderNum;
} }
} }
// 4. Final fallback - for testing, use the known test SIM number // 4. Final fallback - for testing, use the known test SIM number
if (!account) { if (!account) {
// Use the specific test SIM number that should exist in the test environment // Use the specific test SIM number that should exist in the test environment
account = '02000331144508'; account = "02000331144508";
this.logger.warn(`No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`, { this.logger.warn(
userId, `No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`,
subscriptionId, {
productName: subscription.productName, userId,
domain: subscription.domain, subscriptionId,
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], productName: subscription.productName,
note: 'Using known test SIM number 02000331144508 - should exist in Freebit test environment' domain: subscription.domain,
}); customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
note: "Using known test SIM number 02000331144508 - should exist in Freebit test environment",
}
);
} }
// Clean up the account format (remove hyphens, spaces, etc.) // Clean up the account format (remove hyphens, spaces, etc.)
account = account.replace(/[-\s()]/g, ''); account = account.replace(/[-\s()]/g, "");
// Skip phone number format validation for testing // Skip phone number format validation for testing
// In production, you might want to add validation back: // In production, you might want to add validation back:
// const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0 // const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0
@ -198,19 +247,22 @@ export class SimManagementService {
// throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`); // throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`);
// } // }
// account = cleanAccount; // account = cleanAccount;
this.logger.log(`Using SIM account for testing: ${account}`, { this.logger.log(`Using SIM account for testing: ${account}`, {
userId, userId,
subscriptionId, subscriptionId,
account, account,
note: 'Phone number format validation skipped for testing' note: "Phone number format validation skipped for testing",
}); });
return { account }; return { account };
} catch (error) { } catch (error) {
this.logger.error(`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`, { this.logger.error(
error: getErrorMessage(error), `Failed to validate SIM subscription ${subscriptionId} for user ${userId}`,
}); {
error: getErrorMessage(error),
}
);
throw error; throw error;
} }
} }
@ -221,9 +273,9 @@ export class SimManagementService {
async getSimDetails(userId: string, subscriptionId: number): Promise<SimDetails> { async getSimDetails(userId: string, subscriptionId: number): Promise<SimDetails> {
try { try {
const { account } = await this.validateSimSubscription(userId, subscriptionId); const { account } = await this.validateSimSubscription(userId, subscriptionId);
const simDetails = await this.freebititService.getSimDetails(account); const simDetails = await this.freebititService.getSimDetails(account);
this.logger.log(`Retrieved SIM details for subscription ${subscriptionId}`, { this.logger.log(`Retrieved SIM details for subscription ${subscriptionId}`, {
userId, userId,
subscriptionId, subscriptionId,
@ -248,7 +300,7 @@ export class SimManagementService {
async getSimUsage(userId: string, subscriptionId: number): Promise<SimUsage> { async getSimUsage(userId: string, subscriptionId: number): Promise<SimUsage> {
try { try {
const { account } = await this.validateSimSubscription(userId, subscriptionId); const { account } = await this.validateSimSubscription(userId, subscriptionId);
const simUsage = await this.freebititService.getSimUsage(account); const simUsage = await this.freebititService.getSimUsage(account);
// Persist today's usage for monthly charts and cleanup previous months // Persist today's usage for monthly charts and cleanup previous months
@ -264,9 +316,12 @@ export class SimManagementService {
})); }));
} }
} catch (e) { } catch (e) {
this.logger.warn('SIM usage persistence failed (non-fatal)', { account, error: getErrorMessage(e) }); this.logger.warn("SIM usage persistence failed (non-fatal)", {
account,
error: getErrorMessage(e),
});
} }
this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, { this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, {
userId, userId,
subscriptionId, subscriptionId,
@ -292,26 +347,28 @@ export class SimManagementService {
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> { async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
try { try {
const { account } = await this.validateSimSubscription(userId, subscriptionId); const { account } = await this.validateSimSubscription(userId, subscriptionId);
// Validate quota amount // Validate quota amount
if (request.quotaMb <= 0 || request.quotaMb > 100000) { if (request.quotaMb <= 0 || request.quotaMb > 100000) {
throw new BadRequestException('Quota must be between 1MB and 100GB'); throw new BadRequestException("Quota must be between 1MB and 100GB");
} }
// Calculate cost: 1GB = 500 JPY (rounded up to nearest GB) // Calculate cost: 1GB = 500 JPY (rounded up to nearest GB)
const quotaGb = request.quotaMb / 1000; const quotaGb = request.quotaMb / 1000;
const units = Math.ceil(quotaGb); const units = Math.ceil(quotaGb);
const costJpy = units * 500; const costJpy = units * 500;
// Validate quota against Freebit API limits (100MB - 51200MB) // Validate quota against Freebit API limits (100MB - 51200MB)
if (request.quotaMb < 100 || request.quotaMb > 51200) { if (request.quotaMb < 100 || request.quotaMb > 51200) {
throw new BadRequestException('Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility'); throw new BadRequestException(
"Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility"
);
} }
// Get client mapping for WHMCS // Get client mapping for WHMCS
const mapping = await this.mappingsService.findByUserId(userId); const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {
throw new BadRequestException('WHMCS client mapping not found'); throw new BadRequestException("WHMCS client mapping not found");
} }
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, { this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
@ -328,7 +385,7 @@ export class SimManagementService {
clientId: mapping.whmcsClientId, clientId: mapping.whmcsClientId,
description: `SIM Data Top-up: ${units}GB for ${account}`, description: `SIM Data Top-up: ${units}GB for ${account}`,
amount: costJpy, amount: costJpy,
currency: 'JPY', currency: "JPY",
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`, notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`,
}); });
@ -349,7 +406,7 @@ export class SimManagementService {
const paymentResult = await this.whmcsService.capturePayment({ const paymentResult = await this.whmcsService.capturePayment({
invoiceId: invoice.id, invoiceId: invoice.id,
amount: costJpy, amount: costJpy,
currency: 'JPY', currency: "JPY",
}); });
if (!paymentResult.success) { if (!paymentResult.success) {
@ -363,19 +420,19 @@ export class SimManagementService {
try { try {
await this.whmcsService.updateInvoice({ await this.whmcsService.updateInvoice({
invoiceId: invoice.id, invoiceId: invoice.id,
status: 'Cancelled', status: "Cancelled",
notes: `Payment capture failed: ${paymentResult.error}. Invoice cancelled automatically.` notes: `Payment capture failed: ${paymentResult.error}. Invoice cancelled automatically.`,
}); });
this.logger.log(`Cancelled invoice ${invoice.id} due to payment failure`, { this.logger.log(`Cancelled invoice ${invoice.id} due to payment failure`, {
invoiceId: invoice.id, invoiceId: invoice.id,
reason: 'Payment capture failed' reason: "Payment capture failed",
}); });
} catch (cancelError) { } catch (cancelError) {
this.logger.error(`Failed to cancel invoice ${invoice.id} after payment failure`, { this.logger.error(`Failed to cancel invoice ${invoice.id} after payment failure`, {
invoiceId: invoice.id, invoiceId: invoice.id,
cancelError: getErrorMessage(cancelError), cancelError: getErrorMessage(cancelError),
originalError: paymentResult.error originalError: paymentResult.error,
}); });
} }
@ -392,7 +449,7 @@ export class SimManagementService {
try { try {
// Step 3: Only if payment successful, add data via Freebit // Step 3: Only if payment successful, add data via Freebit
await this.freebititService.topUpSim(account, request.quotaMb, {}); await this.freebititService.topUpSim(account, request.quotaMb, {});
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, { this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
userId, userId,
subscriptionId, subscriptionId,
@ -408,36 +465,39 @@ export class SimManagementService {
// 1. Create a refund/credit // 1. Create a refund/credit
// 2. Send notification to admin // 2. Send notification to admin
// 3. Queue for retry // 3. Queue for retry
this.logger.error(`Freebit API failed after successful payment for subscription ${subscriptionId}`, { this.logger.error(
error: getErrorMessage(freebititError), `Freebit API failed after successful payment for subscription ${subscriptionId}`,
userId, {
subscriptionId, error: getErrorMessage(freebititError),
account, userId,
quotaMb: request.quotaMb, subscriptionId,
invoiceId: invoice.id, account,
transactionId: paymentResult.transactionId, quotaMb: request.quotaMb,
paymentCaptured: true, invoiceId: invoice.id,
}); transactionId: paymentResult.transactionId,
paymentCaptured: true,
}
);
// Add a note to the invoice about the Freebit failure // Add a note to the invoice about the Freebit failure
try { try {
await this.whmcsService.updateInvoice({ await this.whmcsService.updateInvoice({
invoiceId: invoice.id, invoiceId: invoice.id,
notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebititError)}. Manual intervention required.` notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebititError)}. Manual intervention required.`,
}); });
this.logger.log(`Added failure note to invoice ${invoice.id}`, { this.logger.log(`Added failure note to invoice ${invoice.id}`, {
invoiceId: invoice.id, invoiceId: invoice.id,
reason: 'Freebit API failure after payment' reason: "Freebit API failure after payment",
}); });
} catch (updateError) { } catch (updateError) {
this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, { this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, {
invoiceId: invoice.id, invoiceId: invoice.id,
updateError: getErrorMessage(updateError), updateError: getErrorMessage(updateError),
originalError: getErrorMessage(freebititError) originalError: getErrorMessage(freebititError),
}); });
} }
// TODO: Implement refund logic here // TODO: Implement refund logic here
// await this.whmcsService.addCredit({ // await this.whmcsService.addCredit({
// clientId: mapping.whmcsClientId, // clientId: mapping.whmcsClientId,
@ -445,7 +505,7 @@ export class SimManagementService {
// amount: costJpy, // amount: costJpy,
// type: 'refund' // type: 'refund'
// }); // });
throw new Error( throw new Error(
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.` `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
); );
@ -465,24 +525,24 @@ export class SimManagementService {
* Get SIM top-up history * Get SIM top-up history
*/ */
async getSimTopUpHistory( async getSimTopUpHistory(
userId: string, userId: string,
subscriptionId: number, subscriptionId: number,
request: SimTopUpHistoryRequest request: SimTopUpHistoryRequest
): Promise<SimTopUpHistory> { ): Promise<SimTopUpHistory> {
try { try {
const { account } = await this.validateSimSubscription(userId, subscriptionId); const { account } = await this.validateSimSubscription(userId, subscriptionId);
// Validate date format // Validate date format
if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) { if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) {
throw new BadRequestException('Dates must be in YYYYMMDD format'); throw new BadRequestException("Dates must be in YYYYMMDD format");
} }
const history = await this.freebititService.getSimTopUpHistory( const history = await this.freebititService.getSimTopUpHistory(
account, account,
request.fromDate, request.fromDate,
request.toDate request.toDate
); );
this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, { this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, {
userId, userId,
subscriptionId, subscriptionId,
@ -505,29 +565,29 @@ export class SimManagementService {
* Change SIM plan * Change SIM plan
*/ */
async changeSimPlan( async changeSimPlan(
userId: string, userId: string,
subscriptionId: number, subscriptionId: number,
request: SimPlanChangeRequest request: SimPlanChangeRequest
): Promise<{ ipv4?: string; ipv6?: string }> { ): Promise<{ ipv4?: string; ipv6?: string }> {
try { try {
const { account } = await this.validateSimSubscription(userId, subscriptionId); const { account } = await this.validateSimSubscription(userId, subscriptionId);
// Validate plan code format // Validate plan code format
if (!request.newPlanCode || request.newPlanCode.length < 3) { if (!request.newPlanCode || request.newPlanCode.length < 3) {
throw new BadRequestException('Invalid plan code'); throw new BadRequestException("Invalid plan code");
} }
// Automatically set to 1st of next month // Automatically set to 1st of next month
const nextMonth = new Date(); const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1); nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1); // Set to 1st of the month nextMonth.setDate(1); // Set to 1st of the month
// Format as YYYYMMDD for Freebit API // Format as YYYYMMDD for Freebit API
const year = nextMonth.getFullYear(); const year = nextMonth.getFullYear();
const month = String(nextMonth.getMonth() + 1).padStart(2, '0'); const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
const day = String(nextMonth.getDate()).padStart(2, '0'); const day = String(nextMonth.getDate()).padStart(2, "0");
const scheduledAt = `${year}${month}${day}`; const scheduledAt = `${year}${month}${day}`;
this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, { this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, {
userId, userId,
subscriptionId, subscriptionId,
@ -539,7 +599,7 @@ export class SimManagementService {
assignGlobalIp: false, // Default to no global IP assignGlobalIp: false, // Default to no global IP
scheduledAt: scheduledAt, scheduledAt: scheduledAt,
}); });
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
userId, userId,
subscriptionId, subscriptionId,
@ -573,7 +633,7 @@ export class SimManagementService {
const { account } = await this.validateSimSubscription(userId, subscriptionId); const { account } = await this.validateSimSubscription(userId, subscriptionId);
// Validate network type if provided // Validate network type if provided
if (request.networkType && !['4G', '5G'].includes(request.networkType)) { if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
throw new BadRequestException('networkType must be either "4G" or "5G"'); throw new BadRequestException('networkType must be either "4G" or "5G"');
} }
@ -599,17 +659,21 @@ export class SimManagementService {
/** /**
* Cancel SIM service * Cancel SIM service
*/ */
async cancelSim(userId: string, subscriptionId: number, request: SimCancelRequest = {}): Promise<void> { async cancelSim(
userId: string,
subscriptionId: number,
request: SimCancelRequest = {}
): Promise<void> {
try { try {
const { account } = await this.validateSimSubscription(userId, subscriptionId); const { account } = await this.validateSimSubscription(userId, subscriptionId);
// Validate scheduled date if provided // Validate scheduled date if provided
if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) { if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) {
throw new BadRequestException('Scheduled date must be in YYYYMMDD format'); throw new BadRequestException("Scheduled date must be in YYYYMMDD format");
} }
await this.freebititService.cancelSim(account, request.scheduledAt); await this.freebititService.cancelSim(account, request.scheduledAt);
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, { this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
userId, userId,
subscriptionId, subscriptionId,
@ -631,15 +695,15 @@ export class SimManagementService {
async reissueEsimProfile(userId: string, subscriptionId: number): Promise<void> { async reissueEsimProfile(userId: string, subscriptionId: number): Promise<void> {
try { try {
const { account } = await this.validateSimSubscription(userId, subscriptionId); const { account } = await this.validateSimSubscription(userId, subscriptionId);
// First check if this is actually an eSIM // First check if this is actually an eSIM
const simDetails = await this.freebititService.getSimDetails(account); const simDetails = await this.freebititService.getSimDetails(account);
if (simDetails.simType !== 'esim') { if (simDetails.simType !== "esim") {
throw new BadRequestException('This operation is only available for eSIM subscriptions'); throw new BadRequestException("This operation is only available for eSIM subscriptions");
} }
await this.freebititService.reissueEsimProfile(account); await this.freebititService.reissueEsimProfile(account);
this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, { this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, {
userId, userId,
subscriptionId, subscriptionId,
@ -658,7 +722,10 @@ export class SimManagementService {
/** /**
* Get comprehensive SIM information (details + usage combined) * Get comprehensive SIM information (details + usage combined)
*/ */
async getSimInfo(userId: string, subscriptionId: number): Promise<{ async getSimInfo(
userId: string,
subscriptionId: number
): Promise<{
details: SimDetails; details: SimDetails;
usage: SimUsage; usage: SimUsage;
}> { }> {
@ -671,9 +738,11 @@ export class SimManagementService {
// If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G) // If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G)
// by subtracting measured usage (today + recentDays) from the plan cap. // by subtracting measured usage (today + recentDays) from the plan cap.
const normalizeNumber = (n: number) => (isFinite(n) && n > 0 ? n : 0); const normalizeNumber = (n: number) => (isFinite(n) && n > 0 ? n : 0);
const usedMb = normalizeNumber(usage.todayUsageMb) + usage.recentDaysUsage.reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0); const usedMb =
normalizeNumber(usage.todayUsageMb) +
usage.recentDaysUsage.reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0);
const planCapMatch = (details.planCode || '').match(/(\d+)\s*G/i); const planCapMatch = (details.planCode || "").match(/(\d+)\s*G/i);
if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) { if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) {
const capGb = parseInt(planCapMatch[1], 10); const capGb = parseInt(planCapMatch[1], 10);
if (!isNaN(capGb) && capGb > 0) { if (!isNaN(capGb) && capGb > 0) {
@ -706,25 +775,25 @@ export class SimManagementService {
const errorLower = technicalError.toLowerCase(); const errorLower = technicalError.toLowerCase();
// Freebit API errors // Freebit API errors
if (errorLower.includes('api error: ng') || errorLower.includes('account not found')) { if (errorLower.includes("api error: ng") || errorLower.includes("account not found")) {
return "SIM account not found. Please contact support to verify your SIM configuration."; return "SIM account not found. Please contact support to verify your SIM configuration.";
} }
if (errorLower.includes('authentication failed') || errorLower.includes('auth')) { if (errorLower.includes("authentication failed") || errorLower.includes("auth")) {
return "SIM service is temporarily unavailable. Please try again later."; return "SIM service is temporarily unavailable. Please try again later.";
} }
if (errorLower.includes('timeout') || errorLower.includes('network')) { if (errorLower.includes("timeout") || errorLower.includes("network")) {
return "SIM service request timed out. Please try again."; return "SIM service request timed out. Please try again.";
} }
// WHMCS errors // WHMCS errors
if (errorLower.includes('invalid permissions') || errorLower.includes('not allowed')) { if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) {
return "SIM service is temporarily unavailable. Please contact support for assistance."; return "SIM service is temporarily unavailable. Please contact support for assistance.";
} }
// Generic errors // Generic errors
if (errorLower.includes('failed') || errorLower.includes('error')) { if (errorLower.includes("failed") || errorLower.includes("error")) {
return "SIM operation failed. Please try again or contact support."; return "SIM operation failed. Please try again or contact support.";
} }

View File

@ -6,14 +6,14 @@ import { Logger } from "nestjs-pino";
export class SimUsageStoreService { export class SimUsageStoreService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger
) {} ) {}
private normalizeDate(date?: Date): Date { private normalizeDate(date?: Date): Date {
const d = date ? new Date(date) : new Date(); const d = date ? new Date(date) : new Date();
// strip time to YYYY-MM-DD // strip time to YYYY-MM-DD
const iso = d.toISOString().split('T')[0]; const iso = d.toISOString().split("T")[0];
return new Date(iso + 'T00:00:00.000Z'); return new Date(iso + "T00:00:00.000Z");
} }
async upsertToday(account: string, usageMb: number, date?: Date): Promise<void> { async upsertToday(account: string, usageMb: number, date?: Date): Promise<void> {
@ -29,21 +29,26 @@ export class SimUsageStoreService {
} }
} }
async getLastNDays(account: string, days = 30): Promise<Array<{ date: string; usageMb: number }>> { async getLastNDays(
account: string,
days = 30
): Promise<Array<{ date: string; usageMb: number }>> {
const end = this.normalizeDate(); const end = this.normalizeDate();
const start = new Date(end); const start = new Date(end);
start.setUTCDate(end.getUTCDate() - (days - 1)); start.setUTCDate(end.getUTCDate() - (days - 1));
const rows = await (this.prisma as any).simUsageDaily.findMany({ const rows = (await (this.prisma as any).simUsageDaily.findMany({
where: { account, date: { gte: start, lte: end } }, where: { account, date: { gte: start, lte: end } },
orderBy: { date: 'desc' }, orderBy: { date: "desc" },
}) as Array<{ date: Date; usageMb: number }>; })) as Array<{ date: Date; usageMb: number }>;
return rows.map((r) => ({ date: r.date.toISOString().split('T')[0], usageMb: r.usageMb })); return rows.map(r => ({ date: r.date.toISOString().split("T")[0], usageMb: r.usageMb }));
} }
async cleanupPreviousMonths(): Promise<number> { async cleanupPreviousMonths(): Promise<number> {
const now = new Date(); const now = new Date();
const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const result = await (this.prisma as any).simUsageDaily.deleteMany({ where: { date: { lt: firstOfMonth } } }); const result = await (this.prisma as any).simUsageDaily.deleteMany({
where: { date: { lt: firstOfMonth } },
});
return result.count; return result.count;
} }
} }

View File

@ -30,7 +30,7 @@ import type { RequestWithUser } from "../auth/auth.types";
export class SubscriptionsController { export class SubscriptionsController {
constructor( constructor(
private readonly subscriptionsService: SubscriptionsService, private readonly subscriptionsService: SubscriptionsService,
private readonly simManagementService: SimManagementService, private readonly simManagementService: SimManagementService
) {} ) {}
@Get() @Get()
@ -270,7 +270,7 @@ export class SubscriptionsController {
if (!fromDate || !toDate) { if (!fromDate || !toDate) {
throw new BadRequestException("fromDate and toDate are required"); throw new BadRequestException("fromDate and toDate are required");
} }
return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, { return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, {
fromDate, fromDate,
toDate, toDate,
@ -297,7 +297,8 @@ export class SubscriptionsController {
async topUpSim( async topUpSim(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: { @Body()
body: {
quotaMb: number; quotaMb: number;
} }
) { ) {
@ -308,7 +309,8 @@ export class SubscriptionsController {
@Post(":id/sim/change-plan") @Post(":id/sim/change-plan")
@ApiOperation({ @ApiOperation({
summary: "Change SIM plan", summary: "Change SIM plan",
description: "Change the SIM service plan. The change will be automatically scheduled for the 1st of the next month.", description:
"Change the SIM service plan. The change will be automatically scheduled for the 1st of the next month.",
}) })
@ApiParam({ name: "id", type: Number, description: "Subscription ID" }) @ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiBody({ @ApiBody({
@ -325,15 +327,16 @@ export class SubscriptionsController {
async changeSimPlan( async changeSimPlan(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: { @Body()
body: {
newPlanCode: string; newPlanCode: string;
} }
) { ) {
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body); const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
return { return {
success: true, success: true,
message: "SIM plan change completed successfully", message: "SIM plan change completed successfully",
...result ...result,
}; };
} }
@ -348,7 +351,11 @@ export class SubscriptionsController {
schema: { schema: {
type: "object", type: "object",
properties: { properties: {
scheduledAt: { type: "string", description: "Schedule cancellation (YYYYMMDD)", example: "20241231" }, scheduledAt: {
type: "string",
description: "Schedule cancellation (YYYYMMDD)",
example: "20241231",
},
}, },
}, },
required: false, required: false,
@ -382,7 +389,8 @@ export class SubscriptionsController {
@Post(":id/sim/features") @Post(":id/sim/features")
@ApiOperation({ @ApiOperation({
summary: "Update SIM features", summary: "Update SIM features",
description: "Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)", description:
"Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)",
}) })
@ApiParam({ name: "id", type: Number, description: "Subscription ID" }) @ApiParam({ name: "id", type: Number, description: "Subscription ID" })
@ApiBody({ @ApiBody({
@ -406,7 +414,7 @@ export class SubscriptionsController {
voiceMailEnabled?: boolean; voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean; callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean; internationalRoamingEnabled?: boolean;
networkType?: '4G' | '5G'; networkType?: "4G" | "5G";
} }
) { ) {
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body); await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);

View File

@ -1,5 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from "@nestjs/common";
import { FreebititService } from './freebit.service'; import { FreebititService } from "./freebit.service";
@Module({ @Module({
providers: [FreebititService], providers: [FreebititService],

View File

@ -1,6 +1,11 @@
import { Injectable, Inject, BadRequestException, InternalServerErrorException } from '@nestjs/common'; import {
import { ConfigService } from '@nestjs/config'; Injectable,
import { Logger } from 'nestjs-pino'; Inject,
BadRequestException,
InternalServerErrorException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { import {
FreebititConfig, FreebititConfig,
FreebititAuthRequest, FreebititAuthRequest,
@ -26,8 +31,8 @@ import {
SimTopUpHistory, SimTopUpHistory,
FreebititError, FreebititError,
FreebititAddSpecRequest, FreebititAddSpecRequest,
FreebititAddSpecResponse FreebititAddSpecResponse,
} from './interfaces/freebit.types'; } from "./interfaces/freebit.types";
@Injectable() @Injectable()
export class FreebititService { export class FreebititService {
@ -39,23 +44,25 @@ export class FreebititService {
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger
) { ) {
this.config = { this.config = {
baseUrl: this.configService.get<string>('FREEBIT_BASE_URL') || 'https://i1-q.mvno.net/emptool/api/', baseUrl:
oemId: this.configService.get<string>('FREEBIT_OEM_ID') || 'PASI', this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1-q.mvno.net/emptool/api/",
oemKey: this.configService.get<string>('FREEBIT_OEM_KEY') || '', oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI",
timeout: this.configService.get<number>('FREEBIT_TIMEOUT') || 30000, oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "",
retryAttempts: this.configService.get<number>('FREEBIT_RETRY_ATTEMPTS') || 3, timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000,
detailsEndpoint: this.configService.get<string>('FREEBIT_DETAILS_ENDPOINT') || '/master/getAcnt/', retryAttempts: this.configService.get<number>("FREEBIT_RETRY_ATTEMPTS") || 3,
detailsEndpoint:
this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
}; };
// Warn if critical configuration is missing // Warn if critical configuration is missing
if (!this.config.oemKey) { if (!this.config.oemKey) {
this.logger.warn('FREEBIT_OEM_KEY is not configured. SIM management features will not work.'); this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work.");
} }
this.logger.debug('Freebit service initialized', { this.logger.debug("Freebit service initialized", {
baseUrl: this.config.baseUrl, baseUrl: this.config.baseUrl,
oemId: this.config.oemId, oemId: this.config.oemId,
hasOemKey: !!this.config.oemKey, hasOemKey: !!this.config.oemKey,
@ -65,19 +72,19 @@ export class FreebititService {
/** /**
* Map Freebit SIM status to portal status * Map Freebit SIM status to portal status
*/ */
private mapSimStatus(freebititStatus: string): 'active' | 'suspended' | 'cancelled' | 'pending' { private mapSimStatus(freebititStatus: string): "active" | "suspended" | "cancelled" | "pending" {
switch (freebititStatus) { switch (freebititStatus) {
case 'active': case "active":
return 'active'; return "active";
case 'suspended': case "suspended":
return 'suspended'; return "suspended";
case 'temporary': case "temporary":
case 'waiting': case "waiting":
return 'pending'; return "pending";
case 'obsolete': case "obsolete":
return 'cancelled'; return "cancelled";
default: default:
return 'pending'; return "pending";
} }
} }
@ -93,7 +100,7 @@ export class FreebititService {
try { try {
// Check if configuration is available // Check if configuration is available
if (!this.config.oemKey) { if (!this.config.oemKey) {
throw new Error('Freebit API not configured: FREEBIT_OEM_KEY is missing'); throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
} }
const request: FreebititAuthRequest = { const request: FreebititAuthRequest = {
@ -102,9 +109,9 @@ export class FreebititService {
}; };
const response = await fetch(`${this.config.baseUrl}/authOem/`, { const response = await fetch(`${this.config.baseUrl}/authOem/`, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', "Content-Type": "application/x-www-form-urlencoded",
}, },
body: `json=${JSON.stringify(request)}`, body: `json=${JSON.stringify(request)}`,
}); });
@ -113,9 +120,9 @@ export class FreebititService {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} }
const data = await response.json() as FreebititAuthResponse; const data = (await response.json()) as FreebititAuthResponse;
if (data.resultCode !== '100') { if (data.resultCode !== "100") {
throw new FreebititErrorImpl( throw new FreebititErrorImpl(
`Authentication failed: ${data.status.message}`, `Authentication failed: ${data.status.message}`,
data.resultCode, data.resultCode,
@ -130,30 +137,27 @@ export class FreebititService {
expiresAt: Date.now() + 50 * 60 * 1000, expiresAt: Date.now() + 50 * 60 * 1000,
}; };
this.logger.log('Successfully authenticated with Freebit API'); this.logger.log("Successfully authenticated with Freebit API");
return data.authKey; return data.authKey;
} catch (error: any) { } catch (error: any) {
this.logger.error('Failed to authenticate with Freebit API', { error: error.message }); this.logger.error("Failed to authenticate with Freebit API", { error: error.message });
throw new InternalServerErrorException('Failed to authenticate with Freebit API'); throw new InternalServerErrorException("Failed to authenticate with Freebit API");
} }
} }
/** /**
* Make authenticated API request with error handling * Make authenticated API request with error handling
*/ */
private async makeAuthenticatedRequest<T>( private async makeAuthenticatedRequest<T>(endpoint: string, data: any): Promise<T> {
endpoint: string,
data: any
): Promise<T> {
const authKey = await this.getAuthKey(); const authKey = await this.getAuthKey();
const requestData = { ...data, authKey }; const requestData = { ...data, authKey };
try { try {
const url = `${this.config.baseUrl}${endpoint}`; const url = `${this.config.baseUrl}${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', "Content-Type": "application/x-www-form-urlencoded",
}, },
body: `json=${JSON.stringify(requestData)}`, body: `json=${JSON.stringify(requestData)}`,
}); });
@ -164,7 +168,7 @@ export class FreebititService {
const text = await response.text(); const text = await response.text();
bodySnippet = text ? text.slice(0, 500) : undefined; bodySnippet = text ? text.slice(0, 500) : undefined;
} catch {} } catch {}
this.logger.error('Freebit API non-OK response', { this.logger.error("Freebit API non-OK response", {
endpoint, endpoint,
url, url,
status: response.status, status: response.status,
@ -174,31 +178,31 @@ export class FreebititService {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} }
const responseData = await response.json() as T; const responseData = (await response.json()) as T;
// Check for API-level errors // Check for API-level errors
if (responseData && (responseData as any).resultCode !== '100') { if (responseData && (responseData as any).resultCode !== "100") {
const errorData = responseData as any; const errorData = responseData as any;
const errorMessage = errorData.status?.message || 'Unknown error'; const errorMessage = errorData.status?.message || "Unknown error";
// Provide more specific error messages for common cases // Provide more specific error messages for common cases
let userFriendlyMessage = `API Error: ${errorMessage}`; let userFriendlyMessage = `API Error: ${errorMessage}`;
if (errorMessage === 'NG') { if (errorMessage === "NG") {
userFriendlyMessage = `Account not found or invalid in Freebit system. Please verify the account number exists and is properly configured.`; userFriendlyMessage = `Account not found or invalid in Freebit system. Please verify the account number exists and is properly configured.`;
} else if (errorMessage.includes('auth') || errorMessage.includes('Auth')) { } else if (errorMessage.includes("auth") || errorMessage.includes("Auth")) {
userFriendlyMessage = `Authentication failed with Freebit API. Please check API credentials.`; userFriendlyMessage = `Authentication failed with Freebit API. Please check API credentials.`;
} else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) { } else if (errorMessage.includes("timeout") || errorMessage.includes("Timeout")) {
userFriendlyMessage = `Request timeout to Freebit API. Please try again later.`; userFriendlyMessage = `Request timeout to Freebit API. Please try again later.`;
} }
this.logger.error('Freebit API error response', { this.logger.error("Freebit API error response", {
endpoint, endpoint,
resultCode: errorData.resultCode, resultCode: errorData.resultCode,
statusCode: errorData.status?.statusCode, statusCode: errorData.status?.statusCode,
message: errorMessage, message: errorMessage,
userFriendlyMessage userFriendlyMessage,
}); });
throw new FreebititErrorImpl( throw new FreebititErrorImpl(
userFriendlyMessage, userFriendlyMessage,
errorData.resultCode, errorData.resultCode,
@ -207,7 +211,7 @@ export class FreebititService {
); );
} }
this.logger.debug('Freebit API Request Success', { this.logger.debug("Freebit API Request Success", {
endpoint, endpoint,
resultCode: (responseData as any).resultCode, resultCode: (responseData as any).resultCode,
}); });
@ -217,9 +221,13 @@ export class FreebititService {
if (error instanceof FreebititErrorImpl) { if (error instanceof FreebititErrorImpl) {
throw error; throw error;
} }
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: (error as any).message }); this.logger.error(`Freebit API request failed: ${endpoint}`, {
throw new InternalServerErrorException(`Freebit API request failed: ${(error as any).message}`); error: (error as any).message,
});
throw new InternalServerErrorException(
`Freebit API request failed: ${(error as any).message}`
);
} }
} }
@ -228,30 +236,32 @@ export class FreebititService {
*/ */
async getSimDetails(account: string): Promise<SimDetails> { async getSimDetails(account: string): Promise<SimDetails> {
try { try {
const request: Omit<FreebititAccountDetailsRequest, 'authKey'> = { const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
version: '2', version: "2",
requestDatas: [{ kind: 'MVNO', account }], requestDatas: [{ kind: "MVNO", account }],
}; };
const configured = this.config.detailsEndpoint || '/master/getAcnt/'; const configured = this.config.detailsEndpoint || "/master/getAcnt/";
const candidates = Array.from(new Set([ const candidates = Array.from(
configured, new Set([
configured.replace(/\/$/, ''), configured,
'/master/getAcnt/', configured.replace(/\/$/, ""),
'/master/getAcnt', "/master/getAcnt/",
'/mvno/getAccountDetail/', "/master/getAcnt",
'/mvno/getAccountDetail', "/mvno/getAccountDetail/",
'/mvno/getAcntDetail/', "/mvno/getAccountDetail",
'/mvno/getAcntDetail', "/mvno/getAcntDetail/",
'/mvno/getAccountInfo/', "/mvno/getAcntDetail",
'/mvno/getAccountInfo', "/mvno/getAccountInfo/",
'/mvno/getSubscriberInfo/', "/mvno/getAccountInfo",
'/mvno/getSubscriberInfo', "/mvno/getSubscriberInfo/",
'/mvno/getInfo/', "/mvno/getSubscriberInfo",
'/mvno/getInfo', "/mvno/getInfo/",
'/master/getDetail/', "/mvno/getInfo",
'/master/getDetail', "/master/getDetail/",
])); "/master/getDetail",
])
);
let response: FreebititAccountDetailsResponse | undefined; let response: FreebititAccountDetailsResponse | undefined;
let lastError: any; let lastError: any;
@ -260,11 +270,14 @@ export class FreebititService {
if (ep !== candidates[0]) { if (ep !== candidates[0]) {
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`); this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
} }
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(ep, request); response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(
ep,
request
);
break; // success break; // success
} catch (err: any) { } catch (err: any) {
lastError = err; lastError = err;
if (typeof err?.message === 'string' && err.message.includes('HTTP 404')) { if (typeof err?.message === "string" && err.message.includes("HTTP 404")) {
// try next candidate // try next candidate
continue; continue;
} }
@ -274,22 +287,27 @@ export class FreebititService {
} }
if (!response) { if (!response) {
throw lastError || new InternalServerErrorException('Failed to fetch SIM details: all endpoints failed'); throw (
lastError ||
new InternalServerErrorException("Failed to fetch SIM details: all endpoints failed")
);
} }
const datas = (response as any).responseDatas; const datas = (response as any).responseDatas;
const list = Array.isArray(datas) ? datas : (datas ? [datas] : []); const list = Array.isArray(datas) ? datas : datas ? [datas] : [];
if (!list.length) { if (!list.length) {
throw new BadRequestException('No SIM details found for this account'); throw new BadRequestException("No SIM details found for this account");
} }
// Prefer the MVNO entry if present // Prefer the MVNO entry if present
const mvno = list.find((d: any) => (d.kind || '').toString().toUpperCase() === 'MVNO') || list[0]; const mvno =
const simData = mvno as any; list.find((d: any) => (d.kind || "").toString().toUpperCase() === "MVNO") || list[0];
const simData = mvno;
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined; const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
const startDate = startDateRaw && /^\d{8}$/.test(startDateRaw) const startDate =
? `${startDateRaw.slice(0,4)}-${startDateRaw.slice(4,6)}-${startDateRaw.slice(6,8)}` startDateRaw && /^\d{8}$/.test(startDateRaw)
: startDateRaw; ? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}`
: startDateRaw;
const simDetails: SimDetails = { const simDetails: SimDetails = {
account: String(simData.account ?? account), account: String(simData.account ?? account),
@ -298,13 +316,14 @@ export class FreebititService {
imsi: simData.imsi ? String(simData.imsi) : undefined, imsi: simData.imsi ? String(simData.imsi) : undefined,
eid: simData.eid, eid: simData.eid,
planCode: simData.planCode, planCode: simData.planCode,
status: this.mapSimStatus(String(simData.state || 'pending')), status: this.mapSimStatus(String(simData.state || "pending")),
simType: simData.eid ? 'esim' : 'physical', simType: simData.eid ? "esim" : "physical",
size: simData.size, size: simData.size,
hasVoice: simData.talk === 10, hasVoice: simData.talk === 10,
hasSms: simData.sms === 10, hasSms: simData.sms === 10,
remainingQuotaKb: typeof simData.quota === 'number' ? simData.quota : 0, remainingQuotaKb: typeof simData.quota === "number" ? simData.quota : 0,
remainingQuotaMb: typeof simData.quota === 'number' ? Math.round((simData.quota / 1000) * 100) / 100 : 0, remainingQuotaMb:
typeof simData.quota === "number" ? Math.round((simData.quota / 1000) * 100) / 100 : 0,
startDate, startDate,
ipv4: simData.ipv4, ipv4: simData.ipv4,
ipv6: simData.ipv6, ipv6: simData.ipv6,
@ -312,10 +331,14 @@ export class FreebititService {
callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10, callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10,
internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10, internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10,
networkType: simData.contractLine || undefined, networkType: simData.contractLine || undefined,
pendingOperations: simData.async ? [{ pendingOperations: simData.async
operation: simData.async.func, ? [
scheduledDate: String(simData.async.date), {
}] : undefined, operation: simData.async.func,
scheduledDate: String(simData.async.date),
},
]
: undefined,
}; };
this.logger.log(`Retrieved SIM details for account ${account}`, { this.logger.log(`Retrieved SIM details for account ${account}`, {
@ -326,7 +349,9 @@ export class FreebititService {
return simDetails; return simDetails;
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to get SIM details for account ${account}`, { error: error.message }); this.logger.error(`Failed to get SIM details for account ${account}`, {
error: error.message,
});
throw error; throw error;
} }
} }
@ -336,26 +361,26 @@ export class FreebititService {
*/ */
async getSimUsage(account: string): Promise<SimUsage> { async getSimUsage(account: string): Promise<SimUsage> {
try { try {
const request: Omit<FreebititTrafficInfoRequest, 'authKey'> = { account }; const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>( const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
'/mvno/getTrafficInfo/', "/mvno/getTrafficInfo/",
request request
); );
const todayUsageKb = parseInt(response.traffic.today, 10) || 0; const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
const recentDaysData = response.traffic.inRecentDays.split(',').map((usage, index) => ({ const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split('T')[0], date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
usageKb: parseInt(usage, 10) || 0, usageKb: parseInt(usage, 10) || 0,
usageMb: Math.round(parseInt(usage, 10) / 1000 * 100) / 100, usageMb: Math.round((parseInt(usage, 10) / 1000) * 100) / 100,
})); }));
const simUsage: SimUsage = { const simUsage: SimUsage = {
account, account,
todayUsageKb, todayUsageKb,
todayUsageMb: Math.round(todayUsageKb / 1000 * 100) / 100, todayUsageMb: Math.round((todayUsageKb / 1000) * 100) / 100,
recentDaysUsage: recentDaysData, recentDaysUsage: recentDaysData,
isBlacklisted: response.traffic.blackList === '10', isBlacklisted: response.traffic.blackList === "10",
}; };
this.logger.log(`Retrieved SIM usage for account ${account}`, { this.logger.log(`Retrieved SIM usage for account ${account}`, {
@ -374,11 +399,15 @@ export class FreebititService {
/** /**
* Top up SIM data quota * Top up SIM data quota
*/ */
async topUpSim(account: string, quotaMb: number, options: { async topUpSim(
campaignCode?: string; account: string,
expiryDate?: string; quotaMb: number,
scheduledAt?: string; options: {
} = {}): Promise<void> { campaignCode?: string;
expiryDate?: string;
scheduledAt?: string;
} = {}
): Promise<void> {
try { try {
// Units per endpoint: // Units per endpoint:
// - Immediate (PA04-04 /master/addSpec/): quota in MB (string), requires kind: 'MVNO' // - Immediate (PA04-04 /master/addSpec/): quota in MB (string), requires kind: 'MVNO'
@ -388,9 +417,9 @@ export class FreebititService {
const quotaKbStr = String(Math.round(quotaKb)); const quotaKbStr = String(Math.round(quotaKb));
const isScheduled = !!options.scheduledAt; const isScheduled = !!options.scheduledAt;
const endpoint = isScheduled ? '/mvno/eachQuota/' : '/master/addSpec/'; const endpoint = isScheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
let request: Omit<FreebititTopUpRequest, 'authKey'>; let request: Omit<FreebititTopUpRequest, "authKey">;
if (isScheduled) { if (isScheduled) {
// PA05-22: KB + runTime // PA05-22: KB + runTime
request = { request = {
@ -404,7 +433,7 @@ export class FreebititService {
// PA04-04: MB + kind // PA04-04: MB + kind
request = { request = {
account, account,
kind: 'MVNO', kind: "MVNO",
quota: quotaMbStr, quota: quotaMbStr,
quotaCode: options.campaignCode, quotaCode: options.campaignCode,
expire: options.expiryDate, expire: options.expiryDate,
@ -418,12 +447,12 @@ export class FreebititService {
endpoint, endpoint,
quotaMb, quotaMb,
quotaKb, quotaKb,
units: isScheduled ? 'KB (PA05-22)' : 'MB (PA04-04)', units: isScheduled ? "KB (PA05-22)" : "MB (PA04-04)",
campaignCode: options.campaignCode, campaignCode: options.campaignCode,
scheduled: isScheduled, scheduled: isScheduled,
}); });
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to top up SIM ${account}`, { this.logger.error(`Failed to top up SIM ${account}`, {
error: error.message, error: error.message,
account, account,
quotaMb, quotaMb,
@ -435,16 +464,20 @@ export class FreebititService {
/** /**
* Get SIM top-up history * Get SIM top-up history
*/ */
async getSimTopUpHistory(account: string, fromDate: string, toDate: string): Promise<SimTopUpHistory> { async getSimTopUpHistory(
account: string,
fromDate: string,
toDate: string
): Promise<SimTopUpHistory> {
try { try {
const request: Omit<FreebititQuotaHistoryRequest, 'authKey'> = { const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = {
account, account,
fromDate, fromDate,
toDate, toDate,
}; };
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>( const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
'/mvno/getQuotaHistory/', "/mvno/getQuotaHistory/",
request request
); );
@ -454,7 +487,7 @@ export class FreebititService {
additionCount: response.count, additionCount: response.count,
history: response.quotaHistory.map(item => ({ history: response.quotaHistory.map(item => ({
quotaKb: parseInt(item.quota, 10), quotaKb: parseInt(item.quota, 10),
quotaMb: Math.round(parseInt(item.quota, 10) / 1000 * 100) / 100, quotaMb: Math.round((parseInt(item.quota, 10) / 1000) * 100) / 100,
addedDate: item.date, addedDate: item.date,
expiryDate: item.expire, expiryDate: item.expire,
campaignCode: item.quotaCode, campaignCode: item.quotaCode,
@ -469,7 +502,9 @@ export class FreebititService {
return history; return history;
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to get SIM top-up history for account ${account}`, { error: error.message }); this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
error: error.message,
});
throw error; throw error;
} }
} }
@ -477,20 +512,24 @@ export class FreebititService {
/** /**
* Change SIM plan * Change SIM plan
*/ */
async changeSimPlan(account: string, newPlanCode: string, options: { async changeSimPlan(
assignGlobalIp?: boolean; account: string,
scheduledAt?: string; newPlanCode: string,
} = {}): Promise<{ ipv4?: string; ipv6?: string }> { options: {
assignGlobalIp?: boolean;
scheduledAt?: string;
} = {}
): Promise<{ ipv4?: string; ipv6?: string }> {
try { try {
const request: Omit<FreebititPlanChangeRequest, 'authKey'> = { const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
account, account,
planCode: newPlanCode, planCode: newPlanCode,
globalip: options.assignGlobalIp ? '1' : '0', globalip: options.assignGlobalIp ? "1" : "0",
runTime: options.scheduledAt, runTime: options.scheduledAt,
}; };
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>( const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
'/mvno/changePlan/', "/mvno/changePlan/",
request request
); );
@ -506,7 +545,7 @@ export class FreebititService {
ipv6: response.ipv6, ipv6: response.ipv6,
}; };
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to change SIM plan for account ${account}`, { this.logger.error(`Failed to change SIM plan for account ${account}`, {
error: error.message, error: error.message,
account, account,
newPlanCode, newPlanCode,
@ -519,35 +558,40 @@ export class FreebititService {
* Update SIM optional features (voicemail, call waiting, international roaming, network type) * Update SIM optional features (voicemail, call waiting, international roaming, network type)
* Uses AddSpec endpoint for immediate changes * Uses AddSpec endpoint for immediate changes
*/ */
async updateSimFeatures(account: string, features: { async updateSimFeatures(
voiceMailEnabled?: boolean; account: string,
callWaitingEnabled?: boolean; features: {
internationalRoamingEnabled?: boolean; voiceMailEnabled?: boolean;
networkType?: string; // '4G' | '5G' callWaitingEnabled?: boolean;
}): Promise<void> { internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G'
}
): Promise<void> {
try { try {
const request: Omit<FreebititAddSpecRequest, 'authKey'> = { const request: Omit<FreebititAddSpecRequest, "authKey"> = {
account, account,
kind: 'MVNO', kind: "MVNO",
}; };
if (typeof features.voiceMailEnabled === 'boolean') { if (typeof features.voiceMailEnabled === "boolean") {
request.voiceMail = features.voiceMailEnabled ? '10' as const : '20' as const; request.voiceMail = features.voiceMailEnabled ? ("10" as const) : ("20" as const);
request.voicemail = request.voiceMail; // include alternate casing for compatibility request.voicemail = request.voiceMail; // include alternate casing for compatibility
} }
if (typeof features.callWaitingEnabled === 'boolean') { if (typeof features.callWaitingEnabled === "boolean") {
request.callWaiting = features.callWaitingEnabled ? '10' as const : '20' as const; request.callWaiting = features.callWaitingEnabled ? ("10" as const) : ("20" as const);
request.callwaiting = request.callWaiting; request.callwaiting = request.callWaiting;
} }
if (typeof features.internationalRoamingEnabled === 'boolean') { if (typeof features.internationalRoamingEnabled === "boolean") {
request.worldWing = features.internationalRoamingEnabled ? '10' as const : '20' as const; request.worldWing = features.internationalRoamingEnabled
? ("10" as const)
: ("20" as const);
request.worldwing = request.worldWing; request.worldwing = request.worldWing;
} }
if (features.networkType) { if (features.networkType) {
request.contractLine = features.networkType; request.contractLine = features.networkType;
} }
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>('/master/addSpec/', request); await this.makeAuthenticatedRequest<FreebititAddSpecResponse>("/master/addSpec/", request);
this.logger.log(`Updated SIM features for account ${account}`, { this.logger.log(`Updated SIM features for account ${account}`, {
account, account,
@ -570,13 +614,13 @@ export class FreebititService {
*/ */
async cancelSim(account: string, scheduledAt?: string): Promise<void> { async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try { try {
const request: Omit<FreebititCancelPlanRequest, 'authKey'> = { const request: Omit<FreebititCancelPlanRequest, "authKey"> = {
account, account,
runTime: scheduledAt, runTime: scheduledAt,
}; };
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>( await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
'/mvno/releasePlan/', "/mvno/releasePlan/",
request request
); );
@ -585,7 +629,7 @@ export class FreebititService {
scheduled: !!scheduledAt, scheduled: !!scheduledAt,
}); });
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to cancel SIM for account ${account}`, { this.logger.error(`Failed to cancel SIM for account ${account}`, {
error: error.message, error: error.message,
account, account,
}); });
@ -603,62 +647,71 @@ export class FreebititService {
// Fetch details to get current EID and plan/network where available // Fetch details to get current EID and plan/network where available
const details = await this.getSimDetails(account); const details = await this.getSimDetails(account);
if (details.simType !== 'esim') { if (details.simType !== "esim") {
throw new BadRequestException('This operation is only available for eSIM subscriptions'); throw new BadRequestException("This operation is only available for eSIM subscriptions");
} }
if (!details.eid) { if (!details.eid) {
throw new BadRequestException('eSIM EID not found for this account'); throw new BadRequestException("eSIM EID not found for this account");
} }
const payload: import('./interfaces/freebit.types').FreebititEsimAccountActivationRequest = { const payload: import("./interfaces/freebit.types").FreebititEsimAccountActivationRequest = {
authKey, authKey,
aladinOperated: '20', aladinOperated: "20",
createType: 'reissue', createType: "reissue",
eid: details.eid, // existing EID used for reissue eid: details.eid, // existing EID used for reissue
account, account,
simkind: 'esim', simkind: "esim",
addKind: 'R', addKind: "R",
// Optional enrichments omitted to minimize validation mismatches // Optional enrichments omitted to minimize validation mismatches
}; };
const url = `${this.config.baseUrl}/mvno/esim/addAcct/`; const url = `${this.config.baseUrl}/mvno/esim/addAcct/`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json; charset=utf-8', "Content-Type": "application/json; charset=utf-8",
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
const text = await response.text().catch(() => ''); const text = await response.text().catch(() => "");
this.logger.error('Freebit PA05-41 HTTP error', { url, status: response.status, statusText: response.statusText, body: text?.slice(0, 500) }); this.logger.error("Freebit PA05-41 HTTP error", {
url,
status: response.status,
statusText: response.statusText,
body: text?.slice(0, 500),
});
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`); throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
} }
const data = await response.json() as import('./interfaces/freebit.types').FreebititEsimAccountActivationResponse; const data =
const rc = typeof data.resultCode === 'number' ? String(data.resultCode) : (data.resultCode || ''); (await response.json()) as import("./interfaces/freebit.types").FreebititEsimAccountActivationResponse;
if (rc !== '100') { const rc =
const message = data.message || 'Unknown error'; typeof data.resultCode === "number" ? String(data.resultCode) : data.resultCode || "";
this.logger.error('Freebit PA05-41 API error response', { if (rc !== "100") {
endpoint: '/mvno/esim/addAcct/', const message = data.message || "Unknown error";
this.logger.error("Freebit PA05-41 API error response", {
endpoint: "/mvno/esim/addAcct/",
resultCode: data.resultCode, resultCode: data.resultCode,
statusCode: data.statusCode, statusCode: data.statusCode,
message, message,
}); });
throw new FreebititErrorImpl( throw new FreebititErrorImpl(
`API Error: ${message}`, `API Error: ${message}`,
rc || '0', rc || "0",
data.statusCode || '0', data.statusCode || "0",
message message
); );
} }
this.logger.log(`Successfully reissued eSIM profile via PA05-41 for account ${account}`, { account }); this.logger.log(`Successfully reissued eSIM profile via PA05-41 for account ${account}`, {
account,
});
} catch (error: any) { } catch (error: any) {
if (error instanceof BadRequestException) throw error; if (error instanceof BadRequestException) throw error;
this.logger.error(`Failed to reissue eSIM profile via PA05-41 for account ${account}`, { this.logger.error(`Failed to reissue eSIM profile via PA05-41 for account ${account}`, {
error: error.message, error: error.message,
account, account,
}); });
@ -670,7 +723,7 @@ export class FreebititService {
* Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation) * Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation)
*/ */
async reissueEsimProfileEnhanced( async reissueEsimProfileEnhanced(
account: string, account: string,
newEid: string, newEid: string,
options: { options: {
oldProductNumber?: string; oldProductNumber?: string;
@ -679,11 +732,11 @@ export class FreebititService {
} = {} } = {}
): Promise<void> { ): Promise<void> {
try { try {
const request: Omit<FreebititEsimAddAccountRequest, 'authKey'> = { const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
aladinOperated: '20', aladinOperated: "20",
account, account,
eid: newEid, eid: newEid,
addKind: 'R', // R = reissue addKind: "R", // R = reissue
reissue: { reissue: {
oldProductNumber: options.oldProductNumber, oldProductNumber: options.oldProductNumber,
oldEid: options.oldEid, oldEid: options.oldEid,
@ -696,18 +749,18 @@ export class FreebititService {
} }
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>( await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
'/mvno/esim/addAcnt/', "/mvno/esim/addAcnt/",
request request
); );
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, { this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
account, account,
newEid, newEid,
oldProductNumber: options.oldProductNumber, oldProductNumber: options.oldProductNumber,
oldEid: options.oldEid, oldEid: options.oldEid,
}); });
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, { this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
error: error.message, error: error.message,
account, account,
newEid, newEid,
@ -724,7 +777,7 @@ export class FreebititService {
await this.getAuthKey(); await this.getAuthKey();
return true; return true;
} catch (error: any) { } catch (error: any) {
this.logger.error('Freebit API health check failed', { error: error.message }); this.logger.error("Freebit API health check failed", { error: error.message });
return false; return false;
} }
} }
@ -736,14 +789,9 @@ class FreebititErrorImpl extends Error {
public readonly statusCode: string; public readonly statusCode: string;
public readonly freebititMessage: string; public readonly freebititMessage: string;
constructor( constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) {
message: string,
resultCode: string,
statusCode: string,
freebititMessage: string
) {
super(message); super(message);
this.name = 'FreebititError'; this.name = "FreebititError";
this.resultCode = resultCode; this.resultCode = resultCode;
this.statusCode = statusCode; this.statusCode = statusCode;
this.freebititMessage = freebititMessage; this.freebititMessage = freebititMessage;

View File

@ -1,8 +1,8 @@
// Freebit API Type Definitions // Freebit API Type Definitions
export interface FreebititAuthRequest { export interface FreebititAuthRequest {
oemId: string; // 4-char alphanumeric ISP identifier oemId: string; // 4-char alphanumeric ISP identifier
oemKey: string; // 32-char auth key oemKey: string; // 32-char auth key
} }
export interface FreebititAuthResponse { export interface FreebititAuthResponse {
@ -11,14 +11,14 @@ export interface FreebititAuthResponse {
message: string; message: string;
statusCode: string; statusCode: string;
}; };
authKey: string; // Token for subsequent API calls authKey: string; // Token for subsequent API calls
} }
export interface FreebititAccountDetailsRequest { export interface FreebititAccountDetailsRequest {
authKey: string; authKey: string;
version?: string | number; // Docs recommend "2" version?: string | number; // Docs recommend "2"
requestDatas: Array<{ requestDatas: Array<{
kind: 'MASTER' | 'MVNO' | string; kind: "MASTER" | "MVNO" | string;
account?: string | number; account?: string | number;
}>; }>;
} }
@ -33,9 +33,9 @@ export interface FreebititAccountDetailsResponse {
// Docs show this can be an array (MASTER + MVNO) or a single object for MVNO // Docs show this can be an array (MASTER + MVNO) or a single object for MVNO
responseDatas: responseDatas:
| { | {
kind: 'MASTER' | 'MVNO' | string; kind: "MASTER" | "MVNO" | string;
account: string | number; account: string | number;
state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string; state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
startDate?: string | number; startDate?: string | number;
relationCode?: string; relationCode?: string;
resultCode?: string | number; resultCode?: string | number;
@ -44,21 +44,21 @@ export interface FreebititAccountDetailsResponse {
imsi?: string | number; imsi?: string | number;
eid?: string; eid?: string;
contractLine?: string; contractLine?: string;
size?: 'standard' | 'nano' | 'micro' | 'esim' | string; size?: "standard" | "nano" | "micro" | "esim" | string;
sms?: number; // 10=active, 20=inactive sms?: number; // 10=active, 20=inactive
talk?: number; // 10=active, 20=inactive talk?: number; // 10=active, 20=inactive
ipv4?: string; ipv4?: string;
ipv6?: string; ipv6?: string;
quota?: number; // Remaining quota (units vary by env) quota?: number; // Remaining quota (units vary by env)
async?: { async?: {
func: 'regist' | 'stop' | 'resume' | 'cancel' | 'pinset' | 'pinunset' | string; func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
date: string | number; date: string | number;
}; };
} }
| Array<{ | Array<{
kind: 'MASTER' | 'MVNO' | string; kind: "MASTER" | "MVNO" | string;
account: string | number; account: string | number;
state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string; state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string;
startDate?: string | number; startDate?: string | number;
relationCode?: string; relationCode?: string;
resultCode?: string | number; resultCode?: string | number;
@ -67,17 +67,17 @@ export interface FreebititAccountDetailsResponse {
imsi?: string | number; imsi?: string | number;
eid?: string; eid?: string;
contractLine?: string; contractLine?: string;
size?: 'standard' | 'nano' | 'micro' | 'esim' | string; size?: "standard" | "nano" | "micro" | "esim" | string;
sms?: number; sms?: number;
talk?: number; talk?: number;
ipv4?: string; ipv4?: string;
ipv6?: string; ipv6?: string;
quota?: number; quota?: number;
async?: { async?: {
func: 'regist' | 'stop' | 'resume' | 'cancel' | 'pinset' | 'pinunset' | string; func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
date: string | number; date: string | number;
}; };
}> }>;
} }
export interface FreebititTrafficInfoRequest { export interface FreebititTrafficInfoRequest {
@ -93,9 +93,9 @@ export interface FreebititTrafficInfoResponse {
}; };
account: string; account: string;
traffic: { traffic: {
today: string; // Today's usage in KB today: string; // Today's usage in KB
inRecentDays: string; // Comma-separated recent days usage inRecentDays: string; // Comma-separated recent days usage
blackList: string; // 10=blacklisted, 20=not blacklisted blackList: string; // 10=blacklisted, 20=not blacklisted
}; };
} }
@ -106,12 +106,12 @@ export interface FreebititTopUpRequest {
// - PA04-04 (/master/addSpec/): MB units (string recommended by spec) // - PA04-04 (/master/addSpec/): MB units (string recommended by spec)
// - PA05-22 (/mvno/eachQuota/): KB units (string recommended by spec) // - PA05-22 (/mvno/eachQuota/): KB units (string recommended by spec)
quota: number | string; quota: number | string;
quotaCode?: string; // Campaign code quotaCode?: string; // Campaign code
expire?: string; // YYYYMMDD format expire?: string; // YYYYMMDD format
// For PA04-04 addSpec // For PA04-04 addSpec
kind?: string; // e.g. 'MVNO' (required by /master/addSpec/) kind?: string; // e.g. 'MVNO' (required by /master/addSpec/)
// For PA05-22 eachQuota // For PA05-22 eachQuota
runTime?: string; // YYYYMMDD or YYYYMMDDhhmmss runTime?: string; // YYYYMMDD or YYYYMMDDhhmmss
} }
export interface FreebititTopUpResponse { export interface FreebititTopUpResponse {
@ -128,12 +128,12 @@ export interface FreebititAddSpecRequest {
account: string; account: string;
kind?: string; // Required by PA04-04 (/master/addSpec/), e.g. 'MVNO' kind?: string; // Required by PA04-04 (/master/addSpec/), e.g. 'MVNO'
// Feature flags: 10 = enabled, 20 = disabled // Feature flags: 10 = enabled, 20 = disabled
voiceMail?: '10' | '20'; voiceMail?: "10" | "20";
voicemail?: '10' | '20'; voicemail?: "10" | "20";
callWaiting?: '10' | '20'; callWaiting?: "10" | "20";
callwaiting?: '10' | '20'; callwaiting?: "10" | "20";
worldWing?: '10' | '20'; worldWing?: "10" | "20";
worldwing?: '10' | '20'; worldwing?: "10" | "20";
contractLine?: string; // '4G' or '5G' contractLine?: string; // '4G' or '5G'
} }
@ -148,8 +148,8 @@ export interface FreebititAddSpecResponse {
export interface FreebititQuotaHistoryRequest { export interface FreebititQuotaHistoryRequest {
authKey: string; authKey: string;
account: string; account: string;
fromDate: string; // YYYYMMDD fromDate: string; // YYYYMMDD
toDate: string; // YYYYMMDD toDate: string; // YYYYMMDD
} }
export interface FreebititQuotaHistoryResponse { export interface FreebititQuotaHistoryResponse {
@ -173,8 +173,8 @@ export interface FreebititPlanChangeRequest {
authKey: string; authKey: string;
account: string; account: string;
planCode: string; planCode: string;
globalip?: '0' | '1'; // 0=no IP, 1=assign global IP globalip?: "0" | "1"; // 0=no IP, 1=assign global IP
runTime?: string; // YYYYMMDD - optional, immediate if omitted runTime?: string; // YYYYMMDD - optional, immediate if omitted
} }
export interface FreebititPlanChangeResponse { export interface FreebititPlanChangeResponse {
@ -190,7 +190,7 @@ export interface FreebititPlanChangeResponse {
export interface FreebititCancelPlanRequest { export interface FreebititCancelPlanRequest {
authKey: string; authKey: string;
account: string; account: string;
runTime?: string; // YYYYMMDD - optional, immediate if omitted runTime?: string; // YYYYMMDD - optional, immediate if omitted
} }
export interface FreebititCancelPlanResponse { export interface FreebititCancelPlanResponse {
@ -219,7 +219,7 @@ export interface FreebititEsimAddAccountRequest {
aladinOperated?: string; aladinOperated?: string;
account: string; account: string;
eid: string; eid: string;
addKind: 'N' | 'R'; // N = new, R = reissue addKind: "N" | "R"; // N = new, R = reissue
createType?: string; createType?: string;
simKind?: string; simKind?: string;
planCode?: string; planCode?: string;
@ -244,13 +244,13 @@ export interface FreebititEsimAccountActivationRequest {
aladinOperated: string; // '10' issue, '20' no-issue aladinOperated: string; // '10' issue, '20' no-issue
masterAccount?: string; masterAccount?: string;
masterPassword?: string; masterPassword?: string;
createType: 'new' | 'reissue' | 'exchange' | string; createType: "new" | "reissue" | "exchange" | string;
eid?: string; // required for reissue/exchange per business rules eid?: string; // required for reissue/exchange per business rules
account: string; // MSISDN account: string; // MSISDN
simkind: 'esim' | string; simkind: "esim" | string;
repAccount?: string; repAccount?: string;
size?: string; size?: string;
addKind?: 'N' | 'R' | string; // e.g., 'R' for reissue addKind?: "N" | "R" | string; // e.g., 'R' for reissue
oldEid?: string; oldEid?: string;
oldProductNumber?: string; oldProductNumber?: string;
mnp?: { mnp?: {
@ -285,9 +285,9 @@ export interface SimDetails {
imsi?: string; imsi?: string;
eid?: string; eid?: string;
planCode: string; planCode: string;
status: 'active' | 'suspended' | 'cancelled' | 'pending'; status: "active" | "suspended" | "cancelled" | "pending";
simType: 'physical' | 'esim'; simType: "physical" | "esim";
size: 'standard' | 'nano' | 'micro' | 'esim'; size: "standard" | "nano" | "micro" | "esim";
hasVoice: boolean; hasVoice: boolean;
hasSms: boolean; hasSms: boolean;
remainingQuotaKb: number; remainingQuotaKb: number;

View File

@ -128,7 +128,9 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
const event = payloadData as Record<string, unknown>; const event = payloadData as Record<string, unknown>;
const payload = ((): Record<string, unknown> | undefined => { const payload = ((): Record<string, unknown> | undefined => {
const p = event?.["payload"]; const p = event?.["payload"];
return typeof p === "object" && p != null ? (p as Record<string, unknown>) : undefined; return typeof p === "object" && p != null
? (p as Record<string, unknown>)
: undefined;
})(); })();
// Only check parsed payload // Only check parsed payload

View File

@ -449,7 +449,9 @@ export class WhmcsConnectionService {
/** /**
* Add a manual payment to an invoice * Add a manual payment to an invoice
*/ */
async addInvoicePayment(params: WhmcsAddInvoicePaymentParams): Promise<WhmcsAddInvoicePaymentResponse> { async addInvoicePayment(
params: WhmcsAddInvoicePaymentParams
): Promise<WhmcsAddInvoicePaymentResponse> {
return this.makeRequest("AddInvoicePayment", params); return this.makeRequest("AddInvoicePayment", params);
} }
} }

View File

@ -5,14 +5,14 @@ import { Invoice, InvoiceList } from "@customer-portal/shared";
import { WhmcsConnectionService } from "./whmcs-connection.service"; import { WhmcsConnectionService } from "./whmcs-connection.service";
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer"; import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
import { WhmcsCacheService } from "../cache/whmcs-cache.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service";
import { import {
WhmcsGetInvoicesParams, WhmcsGetInvoicesParams,
WhmcsCreateInvoiceParams, WhmcsCreateInvoiceParams,
WhmcsCreateInvoiceResponse, WhmcsCreateInvoiceResponse,
WhmcsUpdateInvoiceParams, WhmcsUpdateInvoiceParams,
WhmcsUpdateInvoiceResponse, WhmcsUpdateInvoiceResponse,
WhmcsCapturePaymentParams, WhmcsCapturePaymentParams,
WhmcsCapturePaymentResponse WhmcsCapturePaymentResponse,
} from "../types/whmcs-api.types"; } from "../types/whmcs-api.types";
export interface InvoiceFilters { export interface InvoiceFilters {
@ -250,9 +250,9 @@ export class WhmcsInvoiceService {
notes?: string; notes?: string;
}): Promise<{ id: number; number: string; total: number; status: string }> { }): Promise<{ id: number; number: string; total: number; status: string }> {
try { try {
const dueDateStr = params.dueDate const dueDateStr = params.dueDate
? params.dueDate.toISOString().split('T')[0] ? params.dueDate.toISOString().split("T")[0]
: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; // 7 days from now : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; // 7 days from now
const whmcsParams: WhmcsCreateInvoiceParams = { const whmcsParams: WhmcsCreateInvoiceParams = {
userid: params.clientId, userid: params.clientId,
@ -297,7 +297,14 @@ export class WhmcsInvoiceService {
*/ */
async updateInvoice(params: { async updateInvoice(params: {
invoiceId: number; invoiceId: number;
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending"; status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
dueDate?: Date; dueDate?: Date;
notes?: string; notes?: string;
}): Promise<{ success: boolean; message?: string }> { }): Promise<{ success: boolean; message?: string }> {
@ -305,7 +312,7 @@ export class WhmcsInvoiceService {
const whmcsParams: WhmcsUpdateInvoiceParams = { const whmcsParams: WhmcsUpdateInvoiceParams = {
invoiceid: params.invoiceId, invoiceid: params.invoiceId,
status: params.status, status: params.status,
duedate: params.dueDate ? params.dueDate.toISOString().split('T')[0] : undefined, duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined,
notes: params.notes, notes: params.notes,
}; };
@ -370,8 +377,10 @@ export class WhmcsInvoiceService {
}); });
// Return user-friendly error message instead of technical API error // Return user-friendly error message instead of technical API error
const userFriendlyError = this.getUserFriendlyPaymentError(response.message || response.error || 'Unknown payment error'); const userFriendlyError = this.getUserFriendlyPaymentError(
response.message || response.error || "Unknown payment error"
);
return { return {
success: false, success: false,
error: userFriendlyError, error: userFriendlyError,
@ -385,7 +394,7 @@ export class WhmcsInvoiceService {
// Return user-friendly error message for exceptions // Return user-friendly error message for exceptions
const userFriendlyError = this.getUserFriendlyPaymentError(getErrorMessage(error)); const userFriendlyError = this.getUserFriendlyPaymentError(getErrorMessage(error));
return { return {
success: false, success: false,
error: userFriendlyError, error: userFriendlyError,
@ -404,27 +413,39 @@ export class WhmcsInvoiceService {
const errorLower = technicalError.toLowerCase(); const errorLower = technicalError.toLowerCase();
// WHMCS API permission errors // WHMCS API permission errors
if (errorLower.includes('invalid permissions') || errorLower.includes('not allowed')) { if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) {
return "Payment processing is temporarily unavailable. Please contact support for assistance."; return "Payment processing is temporarily unavailable. Please contact support for assistance.";
} }
// Authentication/authorization errors // Authentication/authorization errors
if (errorLower.includes('unauthorized') || errorLower.includes('forbidden') || errorLower.includes('403')) { if (
errorLower.includes("unauthorized") ||
errorLower.includes("forbidden") ||
errorLower.includes("403")
) {
return "Payment processing is temporarily unavailable. Please contact support for assistance."; return "Payment processing is temporarily unavailable. Please contact support for assistance.";
} }
// Network/timeout errors // Network/timeout errors
if (errorLower.includes('timeout') || errorLower.includes('network') || errorLower.includes('connection')) { if (
errorLower.includes("timeout") ||
errorLower.includes("network") ||
errorLower.includes("connection")
) {
return "Payment processing timed out. Please try again."; return "Payment processing timed out. Please try again.";
} }
// Payment method errors // Payment method errors
if (errorLower.includes('payment method') || errorLower.includes('card') || errorLower.includes('insufficient funds')) { if (
errorLower.includes("payment method") ||
errorLower.includes("card") ||
errorLower.includes("insufficient funds")
) {
return "Unable to process payment with your current payment method. Please check your payment details or try a different method."; return "Unable to process payment with your current payment method. Please check your payment details or try a different method.";
} }
// Generic API errors // Generic API errors
if (errorLower.includes('api') || errorLower.includes('http') || errorLower.includes('error')) { if (errorLower.includes("api") || errorLower.includes("http") || errorLower.includes("error")) {
return "Payment processing failed. Please try again or contact support if the issue persists."; return "Payment processing failed. Please try again or contact support if the issue persists.";
} }

View File

@ -96,7 +96,9 @@ export class WhmcsDataTransformer {
// - Product names often contain "Activation Fee" or "Setup" // - Product names often contain "Activation Fee" or "Setup"
const nameLower = (whmcsProduct.name || whmcsProduct.productname || "").toLowerCase(); const nameLower = (whmcsProduct.name || whmcsProduct.productname || "").toLowerCase();
const looksLikeActivation = const looksLikeActivation =
nameLower.includes("activation fee") || nameLower.includes("activation") || nameLower.includes("setup"); nameLower.includes("activation fee") ||
nameLower.includes("activation") ||
nameLower.includes("setup");
if ((recurringAmount === 0 && firstPaymentAmount > 0) || looksLikeActivation) { if ((recurringAmount === 0 && firstPaymentAmount > 0) || looksLikeActivation) {
normalizedCycle = "One-time"; normalizedCycle = "One-time";

View File

@ -362,7 +362,14 @@ export interface WhmcsPaymentGatewaysResponse {
// CreateInvoice API Types // CreateInvoice API Types
export interface WhmcsCreateInvoiceParams { export interface WhmcsCreateInvoiceParams {
userid: number; userid: number;
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending"; status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
sendnotification?: boolean; sendnotification?: boolean;
paymentmethod?: string; paymentmethod?: string;
taxrate?: number; taxrate?: number;
@ -390,7 +397,14 @@ export interface WhmcsCreateInvoiceResponse {
// UpdateInvoice API Types // UpdateInvoice API Types
export interface WhmcsUpdateInvoiceParams { export interface WhmcsUpdateInvoiceParams {
invoiceid: number; invoiceid: number;
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending"; status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
duedate?: string; // YYYY-MM-DD format duedate?: string; // YYYY-MM-DD format
notes?: string; notes?: string;
[key: string]: unknown; [key: string]: unknown;
@ -403,7 +417,7 @@ export interface WhmcsUpdateInvoiceResponse {
message?: string; message?: string;
} }
// CapturePayment API Types // CapturePayment API Types
export interface WhmcsCapturePaymentParams { export interface WhmcsCapturePaymentParams {
invoiceid: number; invoiceid: number;
cvv?: string; cvv?: string;
@ -460,4 +474,4 @@ export interface WhmcsAddInvoicePaymentParams {
export interface WhmcsAddInvoicePaymentResponse { export interface WhmcsAddInvoicePaymentResponse {
result: "success" | "error"; result: "success" | "error";
message?: string; message?: string;
} }

View File

@ -332,7 +332,14 @@ export class WhmcsService {
*/ */
async updateInvoice(params: { async updateInvoice(params: {
invoiceId: number; invoiceId: number;
status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending"; status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
dueDate?: Date; dueDate?: Date;
notes?: string; notes?: string;
}): Promise<{ success: boolean; message?: string }> { }): Promise<{ success: boolean; message?: string }> {

View File

@ -1,11 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
// Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors // Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors
import { mkdirSync, existsSync, writeFileSync } from 'fs'; import { mkdirSync, existsSync, writeFileSync } from "fs";
import { join } from 'path'; import { join } from "path";
const root = new URL('..', import.meta.url).pathname; // apps/portal const root = new URL("..", import.meta.url).pathname; // apps/portal
const nextDir = join(root, '.next'); const nextDir = join(root, ".next");
const routesManifestPath = join(nextDir, 'routes-manifest.json'); const routesManifestPath = join(nextDir, "routes-manifest.json");
try { try {
mkdirSync(nextDir, { recursive: true }); mkdirSync(nextDir, { recursive: true });
@ -13,17 +13,15 @@ try {
const minimalManifest = { const minimalManifest = {
version: 5, version: 5,
pages404: true, pages404: true,
basePath: '', basePath: "",
redirects: [], redirects: [],
rewrites: { beforeFiles: [], afterFiles: [], fallback: [] }, rewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
headers: [], headers: [],
}; };
writeFileSync(routesManifestPath, JSON.stringify(minimalManifest, null, 2)); writeFileSync(routesManifestPath, JSON.stringify(minimalManifest, null, 2));
// eslint-disable-next-line no-console
console.log('[dev-prep] Created minimal .next/routes-manifest.json'); console.log("[dev-prep] Created minimal .next/routes-manifest.json");
} }
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console console.warn("[dev-prep] Failed to prepare Next dev files:", err?.message || err);
console.warn('[dev-prep] Failed to prepare Next dev files:', err?.message || err);
} }

View File

@ -31,8 +31,8 @@ export default function CatalogPage() {
</span> </span>
</h1> </h1>
<p className="text-xl text-gray-600 max-w-4xl mx-auto leading-relaxed"> <p className="text-xl text-gray-600 max-w-4xl mx-auto leading-relaxed">
Discover high-speed internet, wide range of mobile data options, and secure VPN services. Each Discover high-speed internet, wide range of mobile data options, and secure VPN
solution is personalized based on your location and account eligibility. services. Each solution is personalized based on your location and account eligibility.
</p> </p>
</div> </div>

View File

@ -273,12 +273,16 @@ export default function SimPlansPage() {
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`} }`}
> >
<PhoneIcon className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-voice" ? "scale-110" : ""}`} /> <PhoneIcon
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-voice" ? "scale-110" : ""}`}
/>
Data + SMS/Voice Data + SMS/Voice
{plansByType.DataSmsVoice.length > 0 && ( {plansByType.DataSmsVoice.length > 0 && (
<span className={`bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${ <span
activeTab === "data-voice" ? "scale-110 bg-blue-200" : "" className={`bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
}`}> activeTab === "data-voice" ? "scale-110 bg-blue-200" : ""
}`}
>
{plansByType.DataSmsVoice.length} {plansByType.DataSmsVoice.length}
</span> </span>
)} )}
@ -291,12 +295,16 @@ export default function SimPlansPage() {
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`} }`}
> >
<GlobeAltIcon className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-only" ? "scale-110" : ""}`} /> <GlobeAltIcon
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-only" ? "scale-110" : ""}`}
/>
Data Only Data Only
{plansByType.DataOnly.length > 0 && ( {plansByType.DataOnly.length > 0 && (
<span className={`bg-purple-100 text-purple-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${ <span
activeTab === "data-only" ? "scale-110 bg-purple-200" : "" className={`bg-purple-100 text-purple-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
}`}> activeTab === "data-only" ? "scale-110 bg-purple-200" : ""
}`}
>
{plansByType.DataOnly.length} {plansByType.DataOnly.length}
</span> </span>
)} )}
@ -309,12 +317,16 @@ export default function SimPlansPage() {
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`} }`}
> >
<PhoneIcon className={`h-5 w-5 transition-transform duration-300 ${activeTab === "voice-only" ? "scale-110" : ""}`} /> <PhoneIcon
className={`h-5 w-5 transition-transform duration-300 ${activeTab === "voice-only" ? "scale-110" : ""}`}
/>
Voice Only Voice Only
{plansByType.VoiceOnly.length > 0 && ( {plansByType.VoiceOnly.length > 0 && (
<span className={`bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${ <span
activeTab === "voice-only" ? "scale-110 bg-orange-200" : "" className={`bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
}`}> activeTab === "voice-only" ? "scale-110 bg-orange-200" : ""
}`}
>
{plansByType.VoiceOnly.length} {plansByType.VoiceOnly.length}
</span> </span>
)} )}
@ -325,11 +337,13 @@ export default function SimPlansPage() {
{/* Tab Content */} {/* Tab Content */}
<div className="min-h-[400px] relative"> <div className="min-h-[400px] relative">
<div className={`transition-all duration-500 ease-in-out ${ <div
activeTab === "data-voice" className={`transition-all duration-500 ease-in-out ${
? "opacity-100 translate-y-0" activeTab === "data-voice"
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none" ? "opacity-100 translate-y-0"
}`}> : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
}`}
>
{activeTab === "data-voice" && ( {activeTab === "data-voice" && (
<PlanTypeSection <PlanTypeSection
title="Data + SMS/Voice Plans" title="Data + SMS/Voice Plans"
@ -341,11 +355,13 @@ export default function SimPlansPage() {
)} )}
</div> </div>
<div className={`transition-all duration-500 ease-in-out ${ <div
activeTab === "data-only" className={`transition-all duration-500 ease-in-out ${
? "opacity-100 translate-y-0" activeTab === "data-only"
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none" ? "opacity-100 translate-y-0"
}`}> : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
}`}
>
{activeTab === "data-only" && ( {activeTab === "data-only" && (
<PlanTypeSection <PlanTypeSection
title="Data Only Plans" title="Data Only Plans"
@ -357,11 +373,13 @@ export default function SimPlansPage() {
)} )}
</div> </div>
<div className={`transition-all duration-500 ease-in-out ${ <div
activeTab === "voice-only" className={`transition-all duration-500 ease-in-out ${
? "opacity-100 translate-y-0" activeTab === "voice-only"
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none" ? "opacity-100 translate-y-0"
}`}> : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
}`}
>
{activeTab === "voice-only" && ( {activeTab === "voice-only" && (
<PlanTypeSection <PlanTypeSection
title="Voice Only Plans" title="Voice Only Plans"
@ -437,29 +455,46 @@ export default function SimPlansPage() {
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="font-medium text-blue-900">Contract Period</div> <div className="font-medium text-blue-900">Contract Period</div>
<p className="text-blue-800">Minimum 3 full billing months required. First month (sign-up to end of month) is free and doesn't count toward contract.</p> <p className="text-blue-800">
Minimum 3 full billing months required. First month (sign-up to end of month) is
free and doesn't count toward contract.
</p>
</div> </div>
<div> <div>
<div className="font-medium text-blue-900">Billing Cycle</div> <div className="font-medium text-blue-900">Billing Cycle</div>
<p className="text-blue-800">Monthly billing from 1st to end of month. Regular billing starts on 1st of following month after sign-up.</p> <p className="text-blue-800">
Monthly billing from 1st to end of month. Regular billing starts on 1st of
following month after sign-up.
</p>
</div> </div>
<div> <div>
<div className="font-medium text-blue-900">Cancellation</div> <div className="font-medium text-blue-900">Cancellation</div>
<p className="text-blue-800">Can be requested online after 3rd month. Service terminates at end of billing cycle.</p> <p className="text-blue-800">
Can be requested online after 3rd month. Service terminates at end of billing
cycle.
</p>
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<div className="font-medium text-blue-900">Plan Changes</div> <div className="font-medium text-blue-900">Plan Changes</div>
<p className="text-blue-800">Data plan switching is free and takes effect next month. Voice plan changes require new SIM and cancellation policies apply.</p> <p className="text-blue-800">
Data plan switching is free and takes effect next month. Voice plan changes
require new SIM and cancellation policies apply.
</p>
</div> </div>
<div> <div>
<div className="font-medium text-blue-900">Calling/SMS Charges</div> <div className="font-medium text-blue-900">Calling/SMS Charges</div>
<p className="text-blue-800">Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing cycle.</p> <p className="text-blue-800">
Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing
cycle.
</p>
</div> </div>
<div> <div>
<div className="font-medium text-blue-900">SIM Replacement</div> <div className="font-medium text-blue-900">SIM Replacement</div>
<p className="text-blue-800">Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.</p> <p className="text-blue-800">
Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,7 +3,21 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/layout/page-layout"; import { PageLayout } from "@/components/layout/page-layout";
import { ClipboardDocumentCheckIcon, CheckCircleIcon, WifiIcon, DevicePhoneMobileIcon, LockClosedIcon, CubeIcon, StarIcon, WrenchScrewdriverIcon, PlusIcon, BoltIcon, ExclamationTriangleIcon, EnvelopeIcon, PhoneIcon } from "@heroicons/react/24/outline"; import {
ClipboardDocumentCheckIcon,
CheckCircleIcon,
WifiIcon,
DevicePhoneMobileIcon,
LockClosedIcon,
CubeIcon,
StarIcon,
WrenchScrewdriverIcon,
PlusIcon,
BoltIcon,
ExclamationTriangleIcon,
EnvelopeIcon,
PhoneIcon,
} from "@heroicons/react/24/outline";
import { SubCard } from "@/components/ui/sub-card"; import { SubCard } from "@/components/ui/sub-card";
import { StatusPill } from "@/components/ui/status-pill"; import { StatusPill } from "@/components/ui/status-pill";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
@ -190,8 +204,8 @@ export default function OrderStatusPage() {
Order Submitted Successfully! Order Submitted Successfully!
</h3> </h3>
<p className="text-green-800 mb-3"> <p className="text-green-800 mb-3">
Your order has been created and submitted for processing. We will notify you as Your order has been created and submitted for processing. We will notify you as soon
soon as it&apos;s approved and ready for activation. as it&apos;s approved and ready for activation.
</p> </p>
<div className="text-sm text-green-700"> <div className="text-sm text-green-700">
<p className="mb-1"> <p className="mb-1">
@ -210,7 +224,7 @@ export default function OrderStatusPage() {
)} )}
{/* Status Section - Moved to top */} {/* Status Section - Moved to top */}
{data && ( {data &&
(() => { (() => {
const statusInfo = getDetailedStatusInfo( const statusInfo = getDetailedStatusInfo(
data.status, data.status,
@ -228,11 +242,9 @@ export default function OrderStatusPage() {
: "neutral"; : "neutral";
return ( return (
<SubCard <SubCard
className="mb-9" className="mb-9"
header={ header={<h3 className="text-xl font-bold text-gray-900">Status</h3>}
<h3 className="text-xl font-bold text-gray-900">Status</h3>
}
> >
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<div className="text-gray-700 text-lg sm:text-xl">{statusInfo.description}</div> <div className="text-gray-700 text-lg sm:text-xl">{statusInfo.description}</div>
@ -241,7 +253,7 @@ export default function OrderStatusPage() {
variant={statusVariant as "info" | "success" | "warning" | "error"} variant={statusVariant as "info" | "success" | "warning" | "error"}
/> />
</div> </div>
{/* Highlighted Next Steps Section */} {/* Highlighted Next Steps Section */}
{statusInfo.nextAction && ( {statusInfo.nextAction && (
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4 mb-4 shadow-sm"> <div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4 mb-4 shadow-sm">
@ -252,7 +264,7 @@ export default function OrderStatusPage() {
<p className="text-blue-800 text-base leading-relaxed">{statusInfo.nextAction}</p> <p className="text-blue-800 text-base leading-relaxed">{statusInfo.nextAction}</p>
</div> </div>
)} )}
{statusInfo.timeline && ( {statusInfo.timeline && (
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200"> <div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
@ -262,15 +274,16 @@ export default function OrderStatusPage() {
)} )}
</SubCard> </SubCard>
); );
})() })()}
)}
{/* Combined Service Overview and Products */} {/* Combined Service Overview and Products */}
{data && ( {data && (
<div className="bg-white border rounded-2xl p-4 sm:p-8 mb-8"> <div className="bg-white border rounded-2xl p-4 sm:p-8 mb-8">
{/* Service Header */} {/* Service Header */}
<div className="flex flex-col sm:flex-row items-start gap-4 sm:gap-6 mb-6"> <div className="flex flex-col sm:flex-row items-start gap-4 sm:gap-6 mb-6">
<div className="flex items-center text-3xl sm:text-4xl">{getServiceTypeIcon(data.orderType)}</div> <div className="flex items-center text-3xl sm:text-4xl">
{getServiceTypeIcon(data.orderType)}
</div>
<div className="flex-1"> <div className="flex-1">
<h2 className="text-2xl font-bold text-gray-900 mb-2 flex items-center"> <h2 className="text-2xl font-bold text-gray-900 mb-2 flex items-center">
{data.orderType} Service {data.orderType} Service
@ -341,7 +354,7 @@ export default function OrderStatusPage() {
const bIsService = b.product.itemClass === "Service"; const bIsService = b.product.itemClass === "Service";
const aIsInstallation = a.product.itemClass === "Installation"; const aIsInstallation = a.product.itemClass === "Installation";
const bIsInstallation = b.product.itemClass === "Installation"; const bIsInstallation = b.product.itemClass === "Installation";
if (aIsService && !bIsService) return -1; if (aIsService && !bIsService) return -1;
if (!aIsService && bIsService) return 1; if (!aIsService && bIsService) return 1;
if (aIsInstallation && !bIsInstallation) return -1; if (aIsInstallation && !bIsInstallation) return -1;
@ -349,111 +362,116 @@ export default function OrderStatusPage() {
return 0; return 0;
}) })
.map(item => { .map(item => {
// Use the actual Item_Class__c values from Salesforce documentation // Use the actual Item_Class__c values from Salesforce documentation
const itemClass = item.product.itemClass; const itemClass = item.product.itemClass;
// Get appropriate icon and color based on item type and billing cycle // Get appropriate icon and color based on item type and billing cycle
const getItemTypeInfo = () => { const getItemTypeInfo = () => {
const isMonthly = item.product.billingCycle === "Monthly"; const isMonthly = item.product.billingCycle === "Monthly";
const isService = itemClass === "Service"; const isService = itemClass === "Service";
const isInstallation = itemClass === "Installation"; const isInstallation = itemClass === "Installation";
if (isService && isMonthly) {
// Main service products - Blue theme
return {
icon: <StarIcon className="h-4 w-4" />,
bg: "bg-blue-50 border-blue-200",
iconBg: "bg-blue-100 text-blue-600",
label: itemClass || "Service",
labelColor: "text-blue-600",
};
} else if (isInstallation) {
// Installation items - Green theme
return {
icon: <WrenchScrewdriverIcon className="h-4 w-4" />,
bg: "bg-green-50 border-green-200",
iconBg: "bg-green-100 text-green-600",
label: itemClass || "Installation",
labelColor: "text-green-600",
};
} else if (isMonthly) {
// Other monthly products - Blue theme
return {
icon: <StarIcon className="h-4 w-4" />,
bg: "bg-blue-50 border-blue-200",
iconBg: "bg-blue-100 text-blue-600",
label: itemClass || "Service",
labelColor: "text-blue-600",
};
} else {
// One-time products - Orange theme
return {
icon: <CubeIcon className="h-4 w-4" />,
bg: "bg-orange-50 border-orange-200",
iconBg: "bg-orange-100 text-orange-600",
label: itemClass || "Add-on",
labelColor: "text-orange-600",
};
}
};
const typeInfo = getItemTypeInfo(); if (isService && isMonthly) {
// Main service products - Blue theme
return {
icon: <StarIcon className="h-4 w-4" />,
bg: "bg-blue-50 border-blue-200",
iconBg: "bg-blue-100 text-blue-600",
label: itemClass || "Service",
labelColor: "text-blue-600",
};
} else if (isInstallation) {
// Installation items - Green theme
return {
icon: <WrenchScrewdriverIcon className="h-4 w-4" />,
bg: "bg-green-50 border-green-200",
iconBg: "bg-green-100 text-green-600",
label: itemClass || "Installation",
labelColor: "text-green-600",
};
} else if (isMonthly) {
// Other monthly products - Blue theme
return {
icon: <StarIcon className="h-4 w-4" />,
bg: "bg-blue-50 border-blue-200",
iconBg: "bg-blue-100 text-blue-600",
label: itemClass || "Service",
labelColor: "text-blue-600",
};
} else {
// One-time products - Orange theme
return {
icon: <CubeIcon className="h-4 w-4" />,
bg: "bg-orange-50 border-orange-200",
iconBg: "bg-orange-100 text-orange-600",
label: itemClass || "Add-on",
labelColor: "text-orange-600",
};
}
};
return ( const typeInfo = getItemTypeInfo();
<div key={item.id} className={`rounded-lg p-4 border ${typeInfo.bg} transition-shadow hover:shadow-sm`}>
<div className="flex flex-col sm:flex-row justify-between items-start gap-3">
<div className="flex items-start gap-3 flex-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${typeInfo.iconBg} flex-shrink-0`}
>
{typeInfo.icon}
</div>
<div className="flex-1 min-w-0"> return (
<div className="flex items-center gap-2 mb-1 flex-wrap"> <div
<h3 className="font-semibold text-gray-900 truncate flex-1 min-w-0"> key={item.id}
{item.product.name} className={`rounded-lg p-4 border ${typeInfo.bg} transition-shadow hover:shadow-sm`}
</h3> >
<span <div className="flex flex-col sm:flex-row justify-between items-start gap-3">
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor}`} <div className="flex items-start gap-3 flex-1">
> <div
{typeInfo.label} className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${typeInfo.iconBg} flex-shrink-0`}
</span> >
{typeInfo.icon}
</div> </div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-600"> <div className="flex-1 min-w-0">
<span className="font-medium">{item.product.billingCycle}</span> <div className="flex items-center gap-2 mb-1 flex-wrap">
{item.quantity > 1 && <span>Qty: {item.quantity}</span>} <h3 className="font-semibold text-gray-900 truncate flex-1 min-w-0">
{item.product.itemClass && ( {item.product.name}
<span className="text-xs bg-gray-100 px-2 py-1 rounded"> </h3>
{item.product.itemClass} <span
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor}`}
>
{typeInfo.label}
</span> </span>
)} </div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-600">
<span className="font-medium">{item.product.billingCycle}</span>
{item.quantity > 1 && <span>Qty: {item.quantity}</span>}
{item.product.itemClass && (
<span className="text-xs bg-gray-100 px-2 py-1 rounded">
{item.product.itemClass}
</span>
)}
</div>
</div> </div>
</div> </div>
</div>
<div className="text-left sm:text-right ml-0 sm:ml-3 mt-2 sm:mt-0 flex-shrink-0 sm:w-32"> <div className="text-left sm:text-right ml-0 sm:ml-3 mt-2 sm:mt-0 flex-shrink-0 sm:w-32">
{item.totalPrice && ( {item.totalPrice && (
<div className="font-semibold text-gray-900 tabular-nums"> <div className="font-semibold text-gray-900 tabular-nums">
¥{item.totalPrice.toLocaleString()} ¥{item.totalPrice.toLocaleString()}
</div>
)}
<div className="text-xs text-gray-500">
{item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
</div> </div>
)}
<div className="text-xs text-gray-500">
{item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
</div> </div>
</div> </div>
</div> </div>
</div> );
); })}
})}
{/* Additional fees warning */} {/* Additional fees warning */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-4"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-4">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<ExclamationTriangleIcon className="h-4 w-4 text-yellow-600 flex-shrink-0" /> <ExclamationTriangleIcon className="h-4 w-4 text-yellow-600 flex-shrink-0" />
<div> <div>
<p className="text-sm font-medium text-yellow-900">Additional fees may apply</p> <p className="text-sm font-medium text-yellow-900">
Additional fees may apply
</p>
<p className="text-xs text-yellow-800 mt-1"> <p className="text-xs text-yellow-800 mt-1">
Weekend installation (+¥3,000), express setup, or special configuration Weekend installation (+¥3,000), express setup, or special configuration
charges may be added. We will contact you before applying any additional charges may be added. We will contact you before applying any additional
@ -468,7 +486,6 @@ export default function OrderStatusPage() {
</div> </div>
)} )}
{/* Support Contact */} {/* Support Contact */}
<SubCard title="Need Help?"> <SubCard title="Need Help?">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">

View File

@ -3,7 +3,14 @@
import { useEffect, useState, Suspense } from "react"; import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/layout/page-layout"; import { PageLayout } from "@/components/layout/page-layout";
import { ClipboardDocumentListIcon, CheckCircleIcon, WifiIcon, DevicePhoneMobileIcon, LockClosedIcon, CubeIcon } from "@heroicons/react/24/outline"; import {
ClipboardDocumentListIcon,
CheckCircleIcon,
WifiIcon,
DevicePhoneMobileIcon,
LockClosedIcon,
CubeIcon,
} from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/ui/status-pill"; import { StatusPill } from "@/components/ui/status-pill";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
@ -153,7 +160,7 @@ export default function OrdersPage() {
order.itemsSummary.forEach(item => { order.itemsSummary.forEach(item => {
const totalPrice = item.totalPrice || 0; const totalPrice = item.totalPrice || 0;
const billingCycle = item.billingCycle?.toLowerCase() || ""; const billingCycle = item.billingCycle?.toLowerCase() || "";
if (billingCycle === "monthly") { if (billingCycle === "monthly") {
monthlyTotal += totalPrice; monthlyTotal += totalPrice;
} else { } else {

View File

@ -42,10 +42,10 @@ export default function SubscriptionDetailPage() {
// Control what sections to show based on URL hash // Control what sections to show based on URL hash
useEffect(() => { useEffect(() => {
const updateVisibility = () => { const updateVisibility = () => {
const hash = typeof window !== 'undefined' ? window.location.hash : ''; const hash = typeof window !== "undefined" ? window.location.hash : "";
const service = (searchParams.get('service') || '').toLowerCase(); const service = (searchParams.get("service") || "").toLowerCase();
const isSimContext = hash.includes('sim-management') || service === 'sim'; const isSimContext = hash.includes("sim-management") || service === "sim";
if (isSimContext) { if (isSimContext) {
// Show only SIM management, hide invoices // Show only SIM management, hide invoices
setShowInvoices(false); setShowInvoices(false);
@ -57,9 +57,9 @@ export default function SubscriptionDetailPage() {
} }
}; };
updateVisibility(); updateVisibility();
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.addEventListener('hashchange', updateVisibility); window.addEventListener("hashchange", updateVisibility);
return () => window.removeEventListener('hashchange', updateVisibility); return () => window.removeEventListener("hashchange", updateVisibility);
} }
return; return;
}, [searchParams]); }, [searchParams]);
@ -221,7 +221,6 @@ export default function SubscriptionDetailPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@ -279,21 +278,23 @@ export default function SubscriptionDetailPage() {
</div> </div>
{/* Navigation tabs for SIM services - More visible and mobile-friendly */} {/* Navigation tabs for SIM services - More visible and mobile-friendly */}
{subscription.productName.toLowerCase().includes('sim') && ( {subscription.productName.toLowerCase().includes("sim") && (
<div className="mb-8"> <div className="mb-8">
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6"> <div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0"> <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
<div> <div>
<h3 className="text-xl font-semibold text-gray-900">Service Management</h3> <h3 className="text-xl font-semibold text-gray-900">Service Management</h3>
<p className="text-sm text-gray-600 mt-1">Switch between billing and SIM management views</p> <p className="text-sm text-gray-600 mt-1">
Switch between billing and SIM management views
</p>
</div> </div>
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-100 rounded-xl p-2"> <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-100 rounded-xl p-2">
<Link <Link
href={`/subscriptions/${subscriptionId}#sim-management`} href={`/subscriptions/${subscriptionId}#sim-management`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[140px] text-center ${ className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[140px] text-center ${
showSimManagement showSimManagement
? 'bg-white text-blue-600 shadow-md hover:shadow-lg' ? "bg-white text-blue-600 shadow-md hover:shadow-lg"
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' : "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
}`} }`}
> >
<ServerIcon className="h-4 w-4 inline mr-2" /> <ServerIcon className="h-4 w-4 inline mr-2" />
@ -302,9 +303,9 @@ export default function SubscriptionDetailPage() {
<Link <Link
href={`/subscriptions/${subscriptionId}`} href={`/subscriptions/${subscriptionId}`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[120px] text-center ${ className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[120px] text-center ${
showInvoices showInvoices
? 'bg-white text-blue-600 shadow-md hover:shadow-lg' ? "bg-white text-blue-600 shadow-md hover:shadow-lg"
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' : "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
}`} }`}
> >
<DocumentTextIcon className="h-4 w-4 inline mr-2" /> <DocumentTextIcon className="h-4 w-4 inline mr-2" />
@ -317,186 +318,186 @@ export default function SubscriptionDetailPage() {
)} )}
{/* SIM Management Section - Only show when in SIM context and for SIM services */} {/* SIM Management Section - Only show when in SIM context and for SIM services */}
{showSimManagement && subscription.productName.toLowerCase().includes('sim') && ( {showSimManagement && subscription.productName.toLowerCase().includes("sim") && (
<SimManagementSection subscriptionId={subscriptionId} /> <SimManagementSection subscriptionId={subscriptionId} />
)} )}
{/* Related Invoices (hidden when viewing SIM management directly) */} {/* Related Invoices (hidden when viewing SIM management directly) */}
{showInvoices && ( {showInvoices && (
<div className="bg-white shadow rounded-lg"> <div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center"> <div className="flex items-center">
<DocumentTextIcon className="h-6 w-6 text-blue-600 mr-2" /> <DocumentTextIcon className="h-6 w-6 text-blue-600 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Related Invoices</h3> <h3 className="text-lg font-medium text-gray-900">Related Invoices</h3>
</div>
<p className="text-sm text-gray-500 mt-1">
Invoices containing charges for this subscription
</p>
</div>
{invoicesLoading ? (
<div className="px-6 py-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-600">Loading invoices...</p>
</div>
) : invoicesError ? (
<div className="text-center py-12">
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-400" />
<h3 className="mt-2 text-sm font-medium text-red-800">Error loading invoices</h3>
<p className="mt-1 text-sm text-red-600">
{invoicesError instanceof Error
? invoicesError.message
: "Failed to load related invoices"}
</p>
</div>
) : invoices.length === 0 ? (
<div className="text-center py-12">
<DocumentTextIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No invoices found</h3>
<p className="mt-1 text-sm text-gray-500">
No invoices have been generated for this subscription yet.
</p>
</div>
) : (
<>
<div className="p-6">
<div className="space-y-4">
{invoices.map(invoice => (
<div
key={invoice.id}
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-lg hover:border-blue-200 transition-all duration-200 group"
>
<div className="flex items-start justify-between">
<div className="flex items-center flex-1">
<div className="flex-shrink-0">
{getInvoiceStatusIcon(invoice.status)}
</div>
<div className="ml-3 flex-1">
<h4 className="text-base font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
Invoice {invoice.number}
</h4>
<p className="text-sm text-gray-500 mt-1">
Issued{" "}
{invoice.issuedAt &&
format(new Date(invoice.issuedAt), "MMM d, yyyy")}
</p>
</div>
</div>
<div className="flex flex-col items-end space-y-2">
<span
className={`inline-flex px-3 py-1 text-sm font-medium rounded-full ${getInvoiceStatusColor(invoice.status)}`}
>
{invoice.status}
</span>
<span className="text-lg font-bold text-gray-900">
{formatCurrency(invoice.total)}
</span>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-500">
<span className="block">
Due:{" "}
{invoice.dueDate
? format(new Date(invoice.dueDate), "MMM d, yyyy")
: "N/A"}
</span>
</div>
<button
onClick={() =>
(window.location.href = `/billing/invoices/${invoice.id}`)
}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-sm hover:shadow-md"
>
<DocumentTextIcon className="h-4 w-4 mr-2" />
View Invoice
<ArrowTopRightOnSquareIcon className="h-4 w-4 ml-2" />
</button>
</div>
</div>
))}
</div>
</div> </div>
<p className="text-sm text-gray-500 mt-1">
Invoices containing charges for this subscription
</p>
</div>
{/* Pagination */} {invoicesLoading ? (
{pagination && pagination.totalPages > 1 && ( <div className="px-6 py-8 text-center">
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<div className="flex-1 flex justify-between sm:hidden"> <p className="mt-2 text-gray-600">Loading invoices...</p>
<button </div>
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} ) : invoicesError ? (
disabled={currentPage === 1} <div className="text-center py-12">
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50" <ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-400" />
> <h3 className="mt-2 text-sm font-medium text-red-800">Error loading invoices</h3>
Previous <p className="mt-1 text-sm text-red-600">
</button> {invoicesError instanceof Error
<button ? invoicesError.message
onClick={() => : "Failed to load related invoices"}
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages)) </p>
} </div>
disabled={currentPage === pagination.totalPages} ) : invoices.length === 0 ? (
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50" <div className="text-center py-12">
> <DocumentTextIcon className="mx-auto h-12 w-12 text-gray-400" />
Next <h3 className="mt-2 text-sm font-medium text-gray-900">No invoices found</h3>
</button> <p className="mt-1 text-sm text-gray-500">
</div> No invoices have been generated for this subscription yet.
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> </p>
<div> </div>
<p className="text-sm text-gray-700"> ) : (
Showing{" "} <>
<span className="font-medium"> <div className="p-6">
{(currentPage - 1) * itemsPerPage + 1} <div className="space-y-4">
</span>{" "} {invoices.map(invoice => (
to{" "} <div
<span className="font-medium"> key={invoice.id}
{Math.min(currentPage * itemsPerPage, pagination.totalItems)} className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-lg hover:border-blue-200 transition-all duration-200 group"
</span>{" "} >
of <span className="font-medium">{pagination.totalItems}</span> results <div className="flex items-start justify-between">
</p> <div className="flex items-center flex-1">
</div> <div className="flex-shrink-0">
<div> {getInvoiceStatusIcon(invoice.status)}
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"> </div>
<button <div className="ml-3 flex-1">
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} <h4 className="text-base font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
disabled={currentPage === 1} Invoice {invoice.number}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50" </h4>
> <p className="text-sm text-gray-500 mt-1">
Previous Issued{" "}
</button> {invoice.issuedAt &&
{Array.from({ length: Math.min(pagination.totalPages, 5) }, (_, i) => { format(new Date(invoice.issuedAt), "MMM d, yyyy")}
const startPage = Math.max(1, currentPage - 2); </p>
const page = startPage + i; </div>
if (page > pagination.totalPages) return null; </div>
return ( <div className="flex flex-col items-end space-y-2">
<button <span
key={page} className={`inline-flex px-3 py-1 text-sm font-medium rounded-full ${getInvoiceStatusColor(invoice.status)}`}
onClick={() => setCurrentPage(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage
? "z-10 bg-blue-50 border-blue-500 text-blue-600"
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50"
}`}
> >
{page} {invoice.status}
</button> </span>
); <span className="text-lg font-bold text-gray-900">
})} {formatCurrency(invoice.total)}
<button </span>
onClick={() => </div>
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages)) </div>
} <div className="mt-4 flex items-center justify-between">
disabled={currentPage === pagination.totalPages} <div className="text-sm text-gray-500">
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50" <span className="block">
> Due:{" "}
Next {invoice.dueDate
</button> ? format(new Date(invoice.dueDate), "MMM d, yyyy")
</nav> : "N/A"}
</div> </span>
</div>
<button
onClick={() =>
(window.location.href = `/billing/invoices/${invoice.id}`)
}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-sm hover:shadow-md"
>
<DocumentTextIcon className="h-4 w-4 mr-2" />
View Invoice
<ArrowTopRightOnSquareIcon className="h-4 w-4 ml-2" />
</button>
</div>
</div>
))}
</div> </div>
</div> </div>
)}
</> {/* Pagination */}
)} {pagination && pagination.totalPages > 1 && (
</div> <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Previous
</button>
<button
onClick={() =>
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
}
disabled={currentPage === pagination.totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing{" "}
<span className="font-medium">
{(currentPage - 1) * itemsPerPage + 1}
</span>{" "}
to{" "}
<span className="font-medium">
{Math.min(currentPage * itemsPerPage, pagination.totalItems)}
</span>{" "}
of <span className="font-medium">{pagination.totalItems}</span> results
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
Previous
</button>
{Array.from({ length: Math.min(pagination.totalPages, 5) }, (_, i) => {
const startPage = Math.max(1, currentPage - 2);
const page = startPage + i;
if (page > pagination.totalPages) return null;
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage
? "z-10 bg-blue-50 border-blue-500 text-blue-600"
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50"
}`}
>
{page}
</button>
);
})}
<button
onClick={() =>
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
}
disabled={currentPage === pagination.totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</nav>
</div>
</div>
</div>
)}
</>
)}
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -41,8 +41,8 @@ export default function SimCancelPage() {
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1> <h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
<p className="text-sm text-gray-600 mb-6"> <p className="text-sm text-gray-600 mb-6">
Cancel SIM: Permanently cancel your SIM service. This action cannot be Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will
undone and will terminate your service immediately. terminate your service immediately.
</p> </p>
{message && ( {message && (

View File

@ -7,7 +7,7 @@ import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const; const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
type PlanCode = typeof PLAN_CODES[number]; type PlanCode = (typeof PLAN_CODES)[number];
const PLAN_LABELS: Record<PlanCode, string> = { const PLAN_LABELS: Record<PlanCode, string> = {
PASI_5G: "5GB", PASI_5G: "5GB",
PASI_10G: "10GB", PASI_10G: "10GB",
@ -24,7 +24,10 @@ export default function SimChangePlanPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const options = useMemo(() => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)), [currentPlanCode]); const options = useMemo(
() => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)),
[currentPlanCode]
);
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -51,32 +54,62 @@ export default function SimChangePlanPage() {
<DashboardLayout> <DashboardLayout>
<div className="max-w-3xl mx-auto p-6"> <div className="max-w-3xl mx-auto p-6">
<div className="mb-4"> <div className="mb-4">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link> <Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
>
Back to SIM Management
</Link>
</div> </div>
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-1">Change Plan</h1> <h1 className="text-xl font-semibold text-gray-900 mb-1">Change Plan</h1>
<p className="text-sm text-gray-600 mb-6">Change Plan: Switch to a different data plan. Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month.</p> <p className="text-sm text-gray-600 mb-6">
{message && <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div>} Change Plan: Switch to a different data plan. Important: Plan changes must be requested
{error && <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>} before the 25th of the month. Changes will take effect on the 1st of the following
month.
</p>
{message && (
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
{message}
</div>
)}
{error && (
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</div>
)}
<form onSubmit={submit} className="space-y-6"> <form onSubmit={submit} className="space-y-6">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label> <label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
<select <select
value={newPlanCode} value={newPlanCode}
onChange={(e) => setNewPlanCode(e.target.value as PlanCode)} onChange={e => setNewPlanCode(e.target.value as PlanCode)}
className="w-full px-3 py-2 border border-gray-300 rounded-md" className="w-full px-3 py-2 border border-gray-300 rounded-md"
> >
<option value="">Choose a plan</option> <option value="">Choose a plan</option>
{options.map(code => ( {options.map(code => (
<option key={code} value={code}>{PLAN_LABELS[code]}</option> <option key={code} value={code}>
{PLAN_LABELS[code]}
</option>
))} ))}
</select> </select>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button type="submit" disabled={loading} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">{loading ? 'Processing…' : 'Submit Plan Change'}</button> <button
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Back</Link> type="submit"
disabled={loading}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
>
{loading ? "Processing…" : "Submit Plan Change"}
</button>
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
>
Back
</Link>
</div> </div>
</form> </form>
</div> </div>

View File

@ -1 +1,3 @@
export default function Page(){return null} export default function Page() {
return null;
}

View File

@ -9,7 +9,7 @@ import { authenticatedApi } from "@/lib/api";
export default function SimTopUpPage() { export default function SimTopUpPage() {
const params = useParams(); const params = useParams();
const subscriptionId = parseInt(params.id as string); const subscriptionId = parseInt(params.id as string);
const [gbAmount, setGbAmount] = useState<string>('1'); const [gbAmount, setGbAmount] = useState<string>("1");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -31,23 +31,23 @@ export default function SimTopUpPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!isValidAmount()) { if (!isValidAmount()) {
setError('Please enter a whole number between 1 GB and 100 GB'); setError("Please enter a whole number between 1 GB and 100 GB");
return; return;
} }
setLoading(true); setLoading(true);
setMessage(null); setMessage(null);
setError(null); setError(null);
try { try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, { await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, {
quotaMb: getCurrentAmountMb(), quotaMb: getCurrentAmountMb(),
}); });
setMessage(`Successfully topped up ${gbAmount} GB for ¥${calculateCost().toLocaleString()}`); setMessage(`Successfully topped up ${gbAmount} GB for ¥${calculateCost().toLocaleString()}`);
} catch (e: any) { } catch (e: any) {
setError(e instanceof Error ? e.message : 'Failed to submit top-up'); setError(e instanceof Error ? e.message : "Failed to submit top-up");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -57,19 +57,26 @@ export default function SimTopUpPage() {
<DashboardLayout> <DashboardLayout>
<div className="max-w-3xl mx-auto p-6"> <div className="max-w-3xl mx-auto p-6">
<div className="mb-4"> <div className="mb-4">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link> <Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
>
Back to SIM Management
</Link>
</div> </div>
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-1">Top Up Data</h1> <h1 className="text-xl font-semibold text-gray-900 mb-1">Top Up Data</h1>
<p className="text-sm text-gray-600 mb-6">Add additional data quota to your SIM service. Enter the amount of data you want to add.</p> <p className="text-sm text-gray-600 mb-6">
Add additional data quota to your SIM service. Enter the amount of data you want to add.
</p>
{message && ( {message && (
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3"> <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
{message} {message}
</div> </div>
)} )}
{error && ( {error && (
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3"> <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error} {error}
@ -79,17 +86,15 @@ export default function SimTopUpPage() {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Amount Input */} {/* Amount Input */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
Amount (GB)
</label>
<div className="relative"> <div className="relative">
<input <input
type="number" type="number"
value={gbAmount} value={gbAmount}
onChange={(e) => setGbAmount(e.target.value)} onChange={e => setGbAmount(e.target.value)}
placeholder="Enter amount in GB" placeholder="Enter amount in GB"
min="1" min="1"
max="50" max="50"
step="1" step="1"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 pr-12" className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 pr-12"
/> />
@ -97,9 +102,9 @@ export default function SimTopUpPage() {
<span className="text-gray-500 text-sm">GB</span> <span className="text-gray-500 text-sm">GB</span>
</div> </div>
</div> </div>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Enter the amount of data you want to add (1 - 50 GB, whole numbers) Enter the amount of data you want to add (1 - 50 GB, whole numbers)
</p> </p>
</div> </div>
{/* Cost Display */} {/* Cost Display */}
@ -107,19 +112,15 @@ export default function SimTopUpPage() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<div className="text-sm font-medium text-blue-900"> <div className="text-sm font-medium text-blue-900">
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'} {gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
</div>
<div className="text-xs text-blue-700">
= {getCurrentAmountMb()} MB
</div> </div>
<div className="text-xs text-blue-700">= {getCurrentAmountMb()} MB</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-lg font-bold text-blue-900"> <div className="text-lg font-bold text-blue-900">
¥{calculateCost().toLocaleString()} ¥{calculateCost().toLocaleString()}
</div> </div>
<div className="text-xs text-blue-700"> <div className="text-xs text-blue-700">(1GB = ¥500)</div>
(1GB = ¥500)
</div>
</div> </div>
</div> </div>
</div> </div>
@ -128,12 +129,20 @@ export default function SimTopUpPage() {
{!isValidAmount() && gbAmount && ( {!isValidAmount() && gbAmount && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3"> <div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center"> <div className="flex items-center">
<svg className="h-4 w-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> className="h-4 w-4 text-red-500 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg> </svg>
<p className="text-sm text-red-800"> <p className="text-sm text-red-800">
Amount must be a whole number between 1 GB and 50 GB Amount must be a whole number between 1 GB and 50 GB
</p> </p>
</div> </div>
</div> </div>
)} )}
@ -145,7 +154,7 @@ export default function SimTopUpPage() {
disabled={loading || !isValidAmount()} disabled={loading || !isValidAmount()}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
> >
{loading ? 'Processing...' : `Top Up Now - ¥${calculateCost().toLocaleString()}`} {loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
</button> </button>
<Link <Link
href={`/subscriptions/${subscriptionId}#sim-management`} href={`/subscriptions/${subscriptionId}#sim-management`}

View File

@ -129,10 +129,10 @@ export default function SubscriptionsPage() {
key: "cycle", key: "cycle",
header: "Billing Cycle", header: "Billing Cycle",
render: (subscription: Subscription) => { render: (subscription: Subscription) => {
const name = (subscription.productName || '').toLowerCase(); const name = (subscription.productName || "").toLowerCase();
const looksLikeActivation = const looksLikeActivation =
name.includes('activation fee') || name.includes('activation') || name.includes('setup'); name.includes("activation fee") || name.includes("activation") || name.includes("setup");
const displayCycle = looksLikeActivation ? 'One-time' : subscription.cycle; const displayCycle = looksLikeActivation ? "One-time" : subscription.cycle;
return <span className="text-sm text-gray-900">{displayCycle}</span>; return <span className="text-sm text-gray-900">{displayCycle}</span>;
}, },
}, },

View File

@ -343,7 +343,9 @@ function NavigationItem({
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const isActive = hasChildren const isActive = hasChildren
? item.children?.some((child: NavigationChild) => pathname.startsWith((child.href || "").split(/[?#]/)[0])) || false ? item.children?.some((child: NavigationChild) =>
pathname.startsWith((child.href || "").split(/[?#]/)[0])
) || false
: item.href : item.href
? pathname === item.href ? pathname === item.href
: false; : false;

View File

@ -22,10 +22,7 @@ export function ServiceManagementSection({
subscriptionId, subscriptionId,
productName, productName,
}: ServiceManagementSectionProps) { }: ServiceManagementSectionProps) {
const isSimService = useMemo( const isSimService = useMemo(() => productName?.toLowerCase().includes("sim"), [productName]);
() => productName?.toLowerCase().includes("sim"),
[productName]
);
const [selectedService, setSelectedService] = useState<ServiceKey>( const [selectedService, setSelectedService] = useState<ServiceKey>(
isSimService ? "SIM" : "INTERNET" isSimService ? "SIM" : "INTERNET"
@ -59,7 +56,7 @@ export function ServiceManagementSection({
id="service-selector" id="service-selector"
className="block w-48 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" className="block w-48 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
value={selectedService} value={selectedService}
onChange={(e) => setSelectedService(e.target.value as ServiceKey)} onChange={e => setSelectedService(e.target.value as ServiceKey)}
> >
<option value="SIM">SIM</option> <option value="SIM">SIM</option>
<option value="INTERNET">Internet (coming soon)</option> <option value="INTERNET">Internet (coming soon)</option>
@ -99,12 +96,8 @@ export function ServiceManagementSection({
) : ( ) : (
<div className="bg-white shadow rounded-lg p-6 text-center"> <div className="bg-white shadow rounded-lg p-6 text-center">
<DevicePhoneMobileIcon className="mx-auto h-12 w-12 text-gray-400" /> <DevicePhoneMobileIcon className="mx-auto h-12 w-12 text-gray-400" />
<h4 className="mt-2 text-sm font-medium text-gray-900"> <h4 className="mt-2 text-sm font-medium text-gray-900">SIM management not available</h4>
SIM management not available <p className="mt-1 text-sm text-gray-500">This subscription is not a SIM service.</p>
</h4>
<p className="mt-1 text-sm text-gray-500">
This subscription is not a SIM service.
</p>
</div> </div>
) )
) : selectedService === "INTERNET" ? ( ) : selectedService === "INTERNET" ? (

View File

@ -1 +1 @@
export { ServiceManagementSection } from './components/ServiceManagementSection'; export { ServiceManagementSection } from "./components/ServiceManagementSection";

View File

@ -12,9 +12,15 @@ interface ChangePlanModalProps {
onError: (message: string) => void; onError: (message: string) => void;
} }
export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSuccess, onError }: ChangePlanModalProps) { export function ChangePlanModal({
subscriptionId,
currentPlanCode,
onClose,
onSuccess,
onError,
}: ChangePlanModalProps) {
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const; const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
type PlanCode = typeof PLAN_CODES[number]; type PlanCode = (typeof PLAN_CODES)[number];
const PLAN_LABELS: Record<PlanCode, string> = { const PLAN_LABELS: Record<PlanCode, string> = {
PASI_5G: "5GB", PASI_5G: "5GB",
PASI_10G: "10GB", PASI_10G: "10GB",
@ -22,7 +28,9 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
PASI_50G: "50GB", PASI_50G: "50GB",
}; };
const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(code => code !== (currentPlanCode || '')); const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(
code => code !== (currentPlanCode || "")
);
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>(""); const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -48,9 +56,14 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
return ( return (
<div className="fixed inset-0 z-50 overflow-y-auto"> <div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> <div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"
></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span> <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
@ -63,18 +76,25 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
</div> </div>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">Select New Plan</label> <label className="block text-sm font-medium text-gray-700">
Select New Plan
</label>
<select <select
value={newPlanCode} value={newPlanCode}
onChange={(e) => setNewPlanCode(e.target.value as PlanCode)} onChange={e => setNewPlanCode(e.target.value as PlanCode)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
> >
<option value="">Choose a plan</option> <option value="">Choose a plan</option>
{allowedPlans.map(code => ( {allowedPlans.map(code => (
<option key={code} value={code}>{PLAN_LABELS[code]}</option> <option key={code} value={code}>
{PLAN_LABELS[code]}
</option>
))} ))}
</select> </select>
<p className="mt-1 text-xs text-gray-500">Only plans different from your current plan are listed. The change will be scheduled for the 1st of the next month.</p> <p className="mt-1 text-xs text-gray-500">
Only plans different from your current plan are listed. The change will be
scheduled for the 1st of the next month.
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,10 +1,7 @@
"use client"; "use client";
import React from 'react'; import React from "react";
import { import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
ChartBarIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline';
export interface SimUsage { export interface SimUsage {
account: string; account: string;
@ -26,7 +23,13 @@ interface DataUsageChartProps {
embedded?: boolean; // when true, render content without card container embedded?: boolean; // when true, render content without card container
} }
export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embedded = false }: DataUsageChartProps) { export function DataUsageChart({
usage,
remainingQuotaMb,
isLoading,
error,
embedded = false,
}: DataUsageChartProps) {
const formatUsage = (usageMb: number) => { const formatUsage = (usageMb: number) => {
if (usageMb >= 1000) { if (usageMb >= 1000) {
return `${(usageMb / 1000).toFixed(1)} GB`; return `${(usageMb / 1000).toFixed(1)} GB`;
@ -35,22 +38,22 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
}; };
const getUsageColor = (percentage: number) => { const getUsageColor = (percentage: number) => {
if (percentage >= 90) return 'bg-red-500'; if (percentage >= 90) return "bg-red-500";
if (percentage >= 75) return 'bg-yellow-500'; if (percentage >= 75) return "bg-yellow-500";
if (percentage >= 50) return 'bg-orange-500'; if (percentage >= 50) return "bg-orange-500";
return 'bg-green-500'; return "bg-green-500";
}; };
const getUsageTextColor = (percentage: number) => { const getUsageTextColor = (percentage: number) => {
if (percentage >= 90) return 'text-red-600'; if (percentage >= 90) return "text-red-600";
if (percentage >= 75) return 'text-yellow-600'; if (percentage >= 75) return "text-yellow-600";
if (percentage >= 50) return 'text-orange-600'; if (percentage >= 50) return "text-orange-600";
return 'text-green-600'; return "text-green-600";
}; };
if (isLoading) { if (isLoading) {
return ( return (
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg '}p-6`}> <div className={`${embedded ? "" : "bg-white shadow rounded-lg "}p-6`}>
<div className="animate-pulse"> <div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div> <div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div> <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
@ -66,7 +69,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
if (error) { if (error) {
return ( return (
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg '}p-6`}> <div className={`${embedded ? "" : "bg-white shadow rounded-lg "}p-6`}>
<div className="text-center"> <div className="text-center">
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" /> <ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Usage Data</h3> <h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Usage Data</h3>
@ -77,14 +80,17 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
} }
// Calculate total usage from recent days (assume it includes today) // Calculate total usage from recent days (assume it includes today)
const totalRecentUsage = usage.recentDaysUsage.reduce((sum, day) => sum + day.usageMb, 0) + usage.todayUsageMb; const totalRecentUsage =
usage.recentDaysUsage.reduce((sum, day) => sum + day.usageMb, 0) + usage.todayUsageMb;
const totalQuota = remainingQuotaMb + totalRecentUsage; const totalQuota = remainingQuotaMb + totalRecentUsage;
const usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0; const usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0;
return ( return (
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300'}`}> <div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
>
{/* Header */} {/* Header */}
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-5 border-b border-gray-200'}`}> <div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-blue-50 rounded-xl p-2 mr-4"> <div className="bg-blue-50 rounded-xl p-2 mr-4">
<ChartBarIcon className="h-6 w-6 text-blue-600" /> <ChartBarIcon className="h-6 w-6 text-blue-600" />
@ -97,7 +103,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
</div> </div>
{/* Content */} {/* Content */}
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}> <div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
{/* Current Usage Overview */} {/* Current Usage Overview */}
<div className="mb-6"> <div className="mb-6">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
@ -106,15 +112,15 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
{formatUsage(totalRecentUsage)} of {formatUsage(totalQuota)} {formatUsage(totalRecentUsage)} of {formatUsage(totalQuota)}
</span> </span>
</div> </div>
{/* Progress Bar */} {/* Progress Bar */}
<div className="w-full bg-gray-200 rounded-full h-3"> <div className="w-full bg-gray-200 rounded-full h-3">
<div <div
className={`h-3 rounded-full transition-all duration-300 ${getUsageColor(usagePercentage)}`} className={`h-3 rounded-full transition-all duration-300 ${getUsageColor(usagePercentage)}`}
style={{ width: `${Math.min(usagePercentage, 100)}%` }} style={{ width: `${Math.min(usagePercentage, 100)}%` }}
></div> ></div>
</div> </div>
<div className="flex justify-between text-xs text-gray-500 mt-1"> <div className="flex justify-between text-xs text-gray-500 mt-1">
<span>0%</span> <span>0%</span>
<span className={getUsageTextColor(usagePercentage)}> <span className={getUsageTextColor(usagePercentage)}>
@ -135,13 +141,23 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
<div className="text-sm font-medium text-blue-700 mt-1">Used today</div> <div className="text-sm font-medium text-blue-700 mt-1">Used today</div>
</div> </div>
<div className="bg-blue-200 rounded-full p-3"> <div className="bg-blue-200 rounded-full p-3">
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /> className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg> </svg>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-6 border border-green-200"> <div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-6 border border-green-200">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@ -151,8 +167,18 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
<div className="text-sm font-medium text-green-700 mt-1">Remaining</div> <div className="text-sm font-medium text-green-700 mt-1">Remaining</div>
</div> </div>
<div className="bg-green-200 rounded-full p-3"> <div className="bg-green-200 rounded-full p-3">
<svg className="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4m16 0l-4 4m4-4l-4-4" /> className="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 12H4m16 0l-4 4m4-4l-4-4"
/>
</svg> </svg>
</div> </div>
</div> </div>
@ -171,14 +197,14 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
return ( return (
<div key={index} className="flex items-center justify-between py-2"> <div key={index} className="flex items-center justify-between py-2">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{new Date(day.date).toLocaleDateString('en-US', { {new Date(day.date).toLocaleDateString("en-US", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
})} })}
</span> </span>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-24 bg-gray-200 rounded-full h-2"> <div className="w-24 bg-gray-200 rounded-full h-2">
<div <div
className="bg-blue-500 h-2 rounded-full transition-all duration-300" className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(dayPercentage, 100)}%` }} style={{ width: `${Math.min(dayPercentage, 100)}%` }}
></div> ></div>
@ -216,7 +242,8 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
<div> <div>
<h4 className="text-sm font-medium text-red-800">High Usage Warning</h4> <h4 className="text-sm font-medium text-red-800">High Usage Warning</h4>
<p className="text-sm text-red-700 mt-1"> <p className="text-sm text-red-700 mt-1">
You've used {usagePercentage.toFixed(1)}% of your data quota. Consider topping up to avoid service interruption. You've used {usagePercentage.toFixed(1)}% of your data quota. Consider topping up
to avoid service interruption.
</p> </p>
</div> </div>
</div> </div>
@ -230,7 +257,8 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
<div> <div>
<h4 className="text-sm font-medium text-yellow-800">Usage Notice</h4> <h4 className="text-sm font-medium text-yellow-800">Usage Notice</h4>
<p className="text-sm text-yellow-700 mt-1"> <p className="text-sm text-yellow-700 mt-1">
You've used {usagePercentage.toFixed(1)}% of your data quota. Consider monitoring your usage. You've used {usagePercentage.toFixed(1)}% of your data quota. Consider monitoring
your usage.
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,21 +1,21 @@
"use client"; "use client";
import React, { useState } from 'react'; import React, { useState } from "react";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
import { import {
PlusIcon, PlusIcon,
ArrowPathIcon, ArrowPathIcon,
XMarkIcon, XMarkIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
CheckCircleIcon CheckCircleIcon,
} from '@heroicons/react/24/outline'; } from "@heroicons/react/24/outline";
import { TopUpModal } from './TopUpModal'; import { TopUpModal } from "./TopUpModal";
import { ChangePlanModal } from './ChangePlanModal'; import { ChangePlanModal } from "./ChangePlanModal";
import { authenticatedApi } from '@/lib/api'; import { authenticatedApi } from "@/lib/api";
interface SimActionsProps { interface SimActionsProps {
subscriptionId: number; subscriptionId: number;
simType: 'physical' | 'esim'; simType: "physical" | "esim";
status: string; status: string;
onTopUpSuccess?: () => void; onTopUpSuccess?: () => void;
onPlanChangeSuccess?: () => void; onPlanChangeSuccess?: () => void;
@ -25,16 +25,16 @@ interface SimActionsProps {
currentPlanCode?: string; currentPlanCode?: string;
} }
export function SimActions({ export function SimActions({
subscriptionId, subscriptionId,
simType, simType,
status, status,
onTopUpSuccess, onTopUpSuccess,
onPlanChangeSuccess, onPlanChangeSuccess,
onCancelSuccess, onCancelSuccess,
onReissueSuccess, onReissueSuccess,
embedded = false, embedded = false,
currentPlanCode currentPlanCode,
}: SimActionsProps) { }: SimActionsProps) {
const router = useRouter(); const router = useRouter();
const [showTopUpModal, setShowTopUpModal] = useState(false); const [showTopUpModal, setShowTopUpModal] = useState(false);
@ -45,43 +45,43 @@ export function SimActions({
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [showChangePlanModal, setShowChangePlanModal] = useState(false); const [showChangePlanModal, setShowChangePlanModal] = useState(false);
const [activeInfo, setActiveInfo] = useState< const [activeInfo, setActiveInfo] = useState<
'topup' | 'reissue' | 'cancel' | 'changePlan' | null "topup" | "reissue" | "cancel" | "changePlan" | null
>(null); >(null);
const isActive = status === 'active'; const isActive = status === "active";
const canTopUp = isActive; const canTopUp = isActive;
const canReissue = isActive && simType === 'esim'; const canReissue = isActive && simType === "esim";
const canCancel = isActive; const canCancel = isActive;
const handleReissueEsim = async () => { const handleReissueEsim = async () => {
setLoading('reissue'); setLoading("reissue");
setError(null); setError(null);
try { try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`); await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);
setSuccess('eSIM profile reissued successfully'); setSuccess("eSIM profile reissued successfully");
setShowReissueConfirm(false); setShowReissueConfirm(false);
onReissueSuccess?.(); onReissueSuccess?.();
} catch (error: any) { } catch (error: any) {
setError(error instanceof Error ? error.message : 'Failed to reissue eSIM profile'); setError(error instanceof Error ? error.message : "Failed to reissue eSIM profile");
} finally { } finally {
setLoading(null); setLoading(null);
} }
}; };
const handleCancelSim = async () => { const handleCancelSim = async () => {
setLoading('cancel'); setLoading("cancel");
setError(null); setError(null);
try { try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {}); await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
setSuccess('SIM service cancelled successfully'); setSuccess("SIM service cancelled successfully");
setShowCancelConfirm(false); setShowCancelConfirm(false);
onCancelSuccess?.(); onCancelSuccess?.();
} catch (error: any) { } catch (error: any) {
setError(error instanceof Error ? error.message : 'Failed to cancel SIM service'); setError(error instanceof Error ? error.message : "Failed to cancel SIM service");
} finally { } finally {
setLoading(null); setLoading(null);
} }
@ -100,13 +100,26 @@ export function SimActions({
}, [success, error]); }, [success, error]);
return ( return (
<div id="sim-actions" className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300'}`}> <div
id="sim-actions"
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
>
{/* Header */} {/* Header */}
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-5 border-b border-gray-200'}`}> <div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-blue-50 rounded-xl p-2 mr-4"> <div className="bg-blue-50 rounded-xl p-2 mr-4">
<svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" /> className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
/>
</svg> </svg>
</div> </div>
<div> <div>
@ -117,7 +130,7 @@ export function SimActions({
</div> </div>
{/* Content */} {/* Content */}
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}> <div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
{/* Status Messages */} {/* Status Messages */}
{success && ( {success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4"> <div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
@ -149,11 +162,11 @@ export function SimActions({
)} )}
{/* Action Buttons */} {/* Action Buttons */}
<div className={`grid gap-4 ${embedded ? 'grid-cols-1' : 'grid-cols-2'}`}> <div className={`grid gap-4 ${embedded ? "grid-cols-1" : "grid-cols-2"}`}>
{/* Top Up Data - Primary Action */} {/* Top Up Data - Primary Action */}
<button <button
onClick={() => { onClick={() => {
setActiveInfo('topup'); setActiveInfo("topup");
try { try {
router.push(`/subscriptions/${subscriptionId}/sim/top-up`); router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
} catch { } catch {
@ -163,23 +176,23 @@ export function SimActions({
disabled={!canTopUp || loading !== null} disabled={!canTopUp || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${ className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canTopUp && loading === null canTopUp && loading === null
? 'text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500' ? "text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
: 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed' : "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
}`} }`}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-blue-100 rounded-lg p-1 mr-3"> <div className="bg-blue-100 rounded-lg p-1 mr-3">
<PlusIcon className="h-5 w-5 text-blue-600" /> <PlusIcon className="h-5 w-5 text-blue-600" />
</div> </div>
<span>{loading === 'topup' ? 'Processing...' : 'Top Up Data'}</span> <span>{loading === "topup" ? "Processing..." : "Top Up Data"}</span>
</div> </div>
</button> </button>
{/* Reissue eSIM (only for eSIMs) */} {/* Reissue eSIM (only for eSIMs) */}
{simType === 'esim' && ( {simType === "esim" && (
<button <button
onClick={() => { onClick={() => {
setActiveInfo('reissue'); setActiveInfo("reissue");
try { try {
router.push(`/subscriptions/${subscriptionId}/sim/reissue`); router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
} catch { } catch {
@ -189,15 +202,15 @@ export function SimActions({
disabled={!canReissue || loading !== null} disabled={!canReissue || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${ className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canReissue && loading === null canReissue && loading === null
? 'text-green-700 bg-green-50 border-green-200 hover:bg-green-100 hover:border-green-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500' ? "text-green-700 bg-green-50 border-green-200 hover:bg-green-100 hover:border-green-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
: 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed' : "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
}`} }`}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-green-100 rounded-lg p-1 mr-3"> <div className="bg-green-100 rounded-lg p-1 mr-3">
<ArrowPathIcon className="h-5 w-5 text-green-600" /> <ArrowPathIcon className="h-5 w-5 text-green-600" />
</div> </div>
<span>{loading === 'reissue' ? 'Processing...' : 'Reissue eSIM'}</span> <span>{loading === "reissue" ? "Processing..." : "Reissue eSIM"}</span>
</div> </div>
</button> </button>
)} )}
@ -205,7 +218,7 @@ export function SimActions({
{/* Cancel SIM - Destructive Action */} {/* Cancel SIM - Destructive Action */}
<button <button
onClick={() => { onClick={() => {
setActiveInfo('cancel'); setActiveInfo("cancel");
try { try {
router.push(`/subscriptions/${subscriptionId}/sim/cancel`); router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
} catch { } catch {
@ -216,22 +229,22 @@ export function SimActions({
disabled={!canCancel || loading !== null} disabled={!canCancel || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${ className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canCancel && loading === null canCancel && loading === null
? 'text-red-700 bg-red-50 border-red-200 hover:bg-red-100 hover:border-red-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500' ? "text-red-700 bg-red-50 border-red-200 hover:bg-red-100 hover:border-red-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
: 'text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed' : "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
}`} }`}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-red-100 rounded-lg p-1 mr-3"> <div className="bg-red-100 rounded-lg p-1 mr-3">
<XMarkIcon className="h-5 w-5 text-red-600" /> <XMarkIcon className="h-5 w-5 text-red-600" />
</div> </div>
<span>{loading === 'cancel' ? 'Processing...' : 'Cancel SIM'}</span> <span>{loading === "cancel" ? "Processing..." : "Cancel SIM"}</span>
</div> </div>
</button> </button>
{/* Change Plan - Secondary Action */} {/* Change Plan - Secondary Action */}
<button <button
onClick={() => { onClick={() => {
setActiveInfo('changePlan'); setActiveInfo("changePlan");
try { try {
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`); router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
} catch { } catch {
@ -241,14 +254,24 @@ export function SimActions({
disabled={loading !== null} disabled={loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${ className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${
loading === null loading === null
? 'text-purple-700 bg-purple-50 border-purple-300 hover:bg-purple-100 hover:border-purple-400 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500' ? "text-purple-700 bg-purple-50 border-purple-300 hover:bg-purple-100 hover:border-purple-400 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
: 'text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed' : "text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed"
}`} }`}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-purple-100 rounded-lg p-1 mr-3"> <div className="bg-purple-100 rounded-lg p-1 mr-3">
<svg className="h-5 w-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /> className="h-5 w-5 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg> </svg>
</div> </div>
<span>Change Plan</span> <span>Change Plan</span>
@ -259,37 +282,54 @@ export function SimActions({
{/* Action Description (contextual) */} {/* Action Description (contextual) */}
{activeInfo && ( {activeInfo && (
<div className="mt-6 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-4"> <div className="mt-6 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-4">
{activeInfo === 'topup' && ( {activeInfo === "topup" && (
<div className="flex items-start"> <div className="flex items-start">
<PlusIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" /> <PlusIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
<div> <div>
<strong>Top Up Data:</strong> Add additional data quota to your SIM service. You can choose the amount and schedule it for later if needed. <strong>Top Up Data:</strong> Add additional data quota to your SIM service. You
can choose the amount and schedule it for later if needed.
</div> </div>
</div> </div>
)} )}
{activeInfo === 'reissue' && ( {activeInfo === "reissue" && (
<div className="flex items-start"> <div className="flex items-start">
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" /> <ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
<div> <div>
<strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this if your previous download failed or you need to install on a new device. <strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this
if your previous download failed or you need to install on a new device.
</div> </div>
</div> </div>
)} )}
{activeInfo === 'cancel' && ( {activeInfo === "cancel" && (
<div className="flex items-start"> <div className="flex items-start">
<XMarkIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" /> <XMarkIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
<div> <div>
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action cannot be undone and will terminate your service immediately. <strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action
cannot be undone and will terminate your service immediately.
</div> </div>
</div> </div>
)} )}
{activeInfo === 'changePlan' && ( {activeInfo === "changePlan" && (
<div className="flex items-start"> <div className="flex items-start">
<svg className="h-4 w-4 text-purple-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /> className="h-4 w-4 text-purple-600 mr-2 mt-0.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg> </svg>
<div> <div>
<strong>Change Plan:</strong> Switch to a different data plan. <span className="text-red-600 font-medium">Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month.</span> <strong>Change Plan:</strong> Switch to a different data plan.{" "}
<span className="text-red-600 font-medium">
Important: Plan changes must be requested before the 25th of the month. Changes
will take effect on the 1st of the following month.
</span>
</div> </div>
</div> </div>
)} )}
@ -301,13 +341,16 @@ export function SimActions({
{showTopUpModal && ( {showTopUpModal && (
<TopUpModal <TopUpModal
subscriptionId={subscriptionId} subscriptionId={subscriptionId}
onClose={() => { setShowTopUpModal(false); setActiveInfo(null); }} onClose={() => {
setShowTopUpModal(false);
setActiveInfo(null);
}}
onSuccess={() => { onSuccess={() => {
setShowTopUpModal(false); setShowTopUpModal(false);
setSuccess('Data top-up completed successfully'); setSuccess("Data top-up completed successfully");
onTopUpSuccess?.(); onTopUpSuccess?.();
}} }}
onError={(message) => setError(message)} onError={message => setError(message)}
/> />
)} )}
@ -316,13 +359,16 @@ export function SimActions({
<ChangePlanModal <ChangePlanModal
subscriptionId={subscriptionId} subscriptionId={subscriptionId}
currentPlanCode={currentPlanCode} currentPlanCode={currentPlanCode}
onClose={() => { setShowChangePlanModal(false); setActiveInfo(null); }} onClose={() => {
setShowChangePlanModal(false);
setActiveInfo(null);
}}
onSuccess={() => { onSuccess={() => {
setShowChangePlanModal(false); setShowChangePlanModal(false);
setSuccess('SIM plan change submitted successfully'); setSuccess("SIM plan change submitted successfully");
onPlanChangeSuccess?.(); onPlanChangeSuccess?.();
}} }}
onError={(message) => setError(message)} onError={message => setError(message)}
/> />
)} )}
@ -338,10 +384,13 @@ export function SimActions({
<ArrowPathIcon className="h-6 w-6 text-green-600" /> <ArrowPathIcon className="h-6 w-6 text-green-600" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900">Reissue eSIM Profile</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">
Reissue eSIM Profile
</h3>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
This will generate a new eSIM profile for download. Your current eSIM will remain active until you activate the new profile. This will generate a new eSIM profile for download. Your current eSIM will
remain active until you activate the new profile.
</p> </p>
</div> </div>
</div> </div>
@ -351,15 +400,18 @@ export function SimActions({
<button <button
type="button" type="button"
onClick={handleReissueEsim} onClick={handleReissueEsim}
disabled={loading === 'reissue'} disabled={loading === "reissue"}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50" className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
> >
{loading === 'reissue' ? 'Processing...' : 'Reissue eSIM'} {loading === "reissue" ? "Processing..." : "Reissue eSIM"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => { setShowReissueConfirm(false); setActiveInfo(null); }} onClick={() => {
disabled={loading === 'reissue'} setShowReissueConfirm(false);
setActiveInfo(null);
}}
disabled={loading === "reissue"}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
> >
Back Back
@ -382,10 +434,13 @@ export function SimActions({
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" /> <ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900">Cancel SIM Service</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">
Cancel SIM Service
</h3>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Are you sure you want to cancel this SIM service? This action cannot be undone and will permanently terminate your service. Are you sure you want to cancel this SIM service? This action cannot be
undone and will permanently terminate your service.
</p> </p>
</div> </div>
</div> </div>
@ -395,15 +450,18 @@ export function SimActions({
<button <button
type="button" type="button"
onClick={handleCancelSim} onClick={handleCancelSim}
disabled={loading === 'cancel'} disabled={loading === "cancel"}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50" className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
> >
{loading === 'cancel' ? 'Processing...' : 'Cancel SIM'} {loading === "cancel" ? "Processing..." : "Cancel SIM"}
</button> </button>
<button <button
type="button" type="button"
onClick={() => { setShowCancelConfirm(false); setActiveInfo(null); }} onClick={() => {
disabled={loading === 'cancel'} setShowCancelConfirm(false);
setActiveInfo(null);
}}
disabled={loading === "cancel"}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
> >
Back Back

View File

@ -1,15 +1,15 @@
"use client"; "use client";
import React from 'react'; import React from "react";
import { import {
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
WifiIcon, WifiIcon,
SignalIcon, SignalIcon,
ClockIcon, ClockIcon,
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
XCircleIcon XCircleIcon,
} from '@heroicons/react/24/outline'; } from "@heroicons/react/24/outline";
export interface SimDetails { export interface SimDetails {
account: string; account: string;
@ -18,9 +18,9 @@ export interface SimDetails {
imsi?: string; imsi?: string;
eid?: string; eid?: string;
planCode: string; planCode: string;
status: 'active' | 'suspended' | 'cancelled' | 'pending'; status: "active" | "suspended" | "cancelled" | "pending";
simType: 'physical' | 'esim'; simType: "physical" | "esim";
size: 'standard' | 'nano' | 'micro' | 'esim'; size: "standard" | "nano" | "micro" | "esim";
hasVoice: boolean; hasVoice: boolean;
hasSms: boolean; hasSms: boolean;
remainingQuotaKb: number; remainingQuotaKb: number;
@ -46,25 +46,31 @@ interface SimDetailsCardProps {
showFeaturesSummary?: boolean; // show the right-side Service Features summary showFeaturesSummary?: boolean; // show the right-side Service Features summary
} }
export function SimDetailsCard({ simDetails, isLoading, error, embedded = false, showFeaturesSummary = true }: SimDetailsCardProps) { export function SimDetailsCard({
simDetails,
isLoading,
error,
embedded = false,
showFeaturesSummary = true,
}: SimDetailsCardProps) {
const formatPlan = (code?: string) => { const formatPlan = (code?: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
PASI_5G: '5GB Plan', PASI_5G: "5GB Plan",
PASI_10G: '10GB Plan', PASI_10G: "10GB Plan",
PASI_25G: '25GB Plan', PASI_25G: "25GB Plan",
PASI_50G: '50GB Plan', PASI_50G: "50GB Plan",
}; };
return (code && map[code]) || code || '—'; return (code && map[code]) || code || "—";
}; };
const getStatusIcon = (status: string) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case 'active': case "active":
return <CheckCircleIcon className="h-6 w-6 text-green-500" />; return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
case 'suspended': case "suspended":
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />; return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
case 'cancelled': case "cancelled":
return <XCircleIcon className="h-6 w-6 text-red-500" />; return <XCircleIcon className="h-6 w-6 text-red-500" />;
case 'pending': case "pending":
return <ClockIcon className="h-6 w-6 text-blue-500" />; return <ClockIcon className="h-6 w-6 text-blue-500" />;
default: default:
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />; return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
@ -73,26 +79,26 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'active': case "active":
return 'bg-green-100 text-green-800'; return "bg-green-100 text-green-800";
case 'suspended': case "suspended":
return 'bg-yellow-100 text-yellow-800'; return "bg-yellow-100 text-yellow-800";
case 'cancelled': case "cancelled":
return 'bg-red-100 text-red-800'; return "bg-red-100 text-red-800";
case 'pending': case "pending":
return 'bg-blue-100 text-blue-800'; return "bg-blue-100 text-blue-800";
default: default:
return 'bg-gray-100 text-gray-800'; return "bg-gray-100 text-gray-800";
} }
}; };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
try { try {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString('en-US', { return date.toLocaleDateString("en-US", {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric', day: "numeric",
}); });
} catch { } catch {
return dateString; return dateString;
@ -108,7 +114,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
if (isLoading) { if (isLoading) {
const Skeleton = ( const Skeleton = (
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 '}p-6 lg:p-8`}> <div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 "}p-6 lg:p-8`}
>
<div className="animate-pulse"> <div className="animate-pulse">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div> <div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
@ -130,7 +138,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
if (error) { if (error) {
return ( return (
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-red-100 '}p-6 lg:p-8`}> <div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-red-100 "}p-6 lg:p-8`}
>
<div className="text-center"> <div className="text-center">
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4"> <div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" /> <ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
@ -143,11 +153,13 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
} }
// Specialized, minimal eSIM details view // Specialized, minimal eSIM details view
if (simDetails.simType === 'esim') { if (simDetails.simType === "esim") {
return ( return (
<div className={`${embedded ? '' : 'bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300'}`}> <div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
>
{/* Header */} {/* Header */}
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-5 border-b border-gray-200'}`}> <div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0">
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-blue-50 rounded-xl p-2 mr-4"> <div className="bg-blue-50 rounded-xl p-2 mr-4">
@ -155,16 +167,20 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3> <h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3>
<p className="text-sm text-gray-600 font-medium">Current Plan: {formatPlan(simDetails.planCode)}</p> <p className="text-sm text-gray-600 font-medium">
Current Plan: {formatPlan(simDetails.planCode)}
</p>
</div> </div>
</div> </div>
<span className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}> <span
className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)} {simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span> </span>
</div> </div>
</div> </div>
<div className={`${embedded ? '' : 'px-6 lg:px-8 py-6'}`}> <div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@ -174,15 +190,21 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
</h4> </h4>
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<div> <div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Phone Number</label> <label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Phone Number
</label>
<p className="text-lg font-semibold text-gray-900 mt-1">{simDetails.msisdn}</p> <p className="text-lg font-semibold text-gray-900 mt-1">{simDetails.msisdn}</p>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Data Remaining</label> <label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
<p className="text-2xl font-bold text-green-600 mt-1">{formatQuota(simDetails.remainingQuotaMb)}</p> Data Remaining
</label>
<p className="text-2xl font-bold text-green-600 mt-1">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div> </div>
</div> </div>
@ -195,26 +217,32 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg"> <div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Voice Mail (¥300/month)</span> <span className="text-sm text-gray-700">Voice Mail (¥300/month)</span>
<span className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.voiceMailEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}> <span
{simDetails.voiceMailEnabled ? 'Enabled' : 'Disabled'} className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.voiceMailEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{simDetails.voiceMailEnabled ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg"> <div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Call Waiting (¥300/month)</span> <span className="text-sm text-gray-700">Call Waiting (¥300/month)</span>
<span className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.callWaitingEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}> <span
{simDetails.callWaitingEnabled ? 'Enabled' : 'Disabled'} className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.callWaitingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{simDetails.callWaitingEnabled ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg"> <div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">International Roaming</span> <span className="text-sm text-gray-700">International Roaming</span>
<span className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.internationalRoamingEnabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}> <span
{simDetails.internationalRoamingEnabled ? 'Enabled' : 'Disabled'} className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.internationalRoamingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{simDetails.internationalRoamingEnabled ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="flex justify-between items-center py-2 px-3 bg-blue-50 rounded-lg"> <div className="flex justify-between items-center py-2 px-3 bg-blue-50 rounded-lg">
<span className="text-sm text-gray-700">4G/5G</span> <span className="text-sm text-gray-700">4G/5G</span>
<span className="text-sm font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800"> <span className="text-sm font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800">
{simDetails.networkType || '5G'} {simDetails.networkType || "5G"}
</span> </span>
</div> </div>
</div> </div>
@ -227,9 +255,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
} }
return ( return (
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg'}`}> <div className={`${embedded ? "" : "bg-white shadow rounded-lg"}`}>
{/* Header */} {/* Header */}
<div className={`${embedded ? '' : 'px-6 py-4 border-b border-gray-200'}`}> <div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<div className="text-2xl mr-3"> <div className="text-2xl mr-3">
@ -244,7 +272,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{getStatusIcon(simDetails.status)} {getStatusIcon(simDetails.status)}
<span className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}> <span
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)} {simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span> </span>
</div> </div>
@ -252,7 +282,7 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
</div> </div>
{/* Content */} {/* Content */}
<div className={`${embedded ? '' : 'px-6 py-4'}`}> <div className={`${embedded ? "" : "px-6 py-4"}`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* SIM Information */} {/* SIM Information */}
<div> <div>
@ -264,8 +294,8 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
<label className="text-xs text-gray-500">Phone Number</label> <label className="text-xs text-gray-500">Phone Number</label>
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p> <p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
</div> </div>
{simDetails.simType === 'physical' && ( {simDetails.simType === "physical" && (
<div> <div>
<label className="text-xs text-gray-500">ICCID</label> <label className="text-xs text-gray-500">ICCID</label>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p> <p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
@ -304,20 +334,30 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-xs text-gray-500">Data Remaining</label> <label className="text-xs text-gray-500">Data Remaining</label>
<p className="text-lg font-semibold text-green-600">{formatQuota(simDetails.remainingQuotaMb)}</p> <p className="text-lg font-semibold text-green-600">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex items-center"> <div className="flex items-center">
<SignalIcon className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? 'text-green-500' : 'text-gray-400'}`} /> <SignalIcon
<span className={`text-sm ${simDetails.hasVoice ? 'text-green-600' : 'text-gray-500'}`}> className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? "text-green-500" : "text-gray-400"}`}
Voice {simDetails.hasVoice ? 'Enabled' : 'Disabled'} />
<span
className={`text-sm ${simDetails.hasVoice ? "text-green-600" : "text-gray-500"}`}
>
Voice {simDetails.hasVoice ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<DevicePhoneMobileIcon className={`h-4 w-4 mr-1 ${simDetails.hasSms ? 'text-green-500' : 'text-gray-400'}`} /> <DevicePhoneMobileIcon
<span className={`text-sm ${simDetails.hasSms ? 'text-green-600' : 'text-gray-500'}`}> className={`h-4 w-4 mr-1 ${simDetails.hasSms ? "text-green-500" : "text-gray-400"}`}
SMS {simDetails.hasSms ? 'Enabled' : 'Disabled'} />
<span
className={`text-sm ${simDetails.hasSms ? "text-green-600" : "text-gray-500"}`}
>
SMS {simDetails.hasSms ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
</div> </div>

View File

@ -23,18 +23,21 @@ export function SimFeatureToggles({
embedded = false, embedded = false,
}: SimFeatureTogglesProps) { }: SimFeatureTogglesProps) {
// Initial values // Initial values
const initial = useMemo(() => ({ const initial = useMemo(
vm: !!voiceMailEnabled, () => ({
cw: !!callWaitingEnabled, vm: !!voiceMailEnabled,
ir: !!internationalRoamingEnabled, cw: !!callWaitingEnabled,
nt: networkType === '5G' ? '5G' : '4G', ir: !!internationalRoamingEnabled,
}), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]); nt: networkType === "5G" ? "5G" : "4G",
}),
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
);
// Working values // Working values
const [vm, setVm] = useState(initial.vm); const [vm, setVm] = useState(initial.vm);
const [cw, setCw] = useState(initial.cw); const [cw, setCw] = useState(initial.cw);
const [ir, setIr] = useState(initial.ir); const [ir, setIr] = useState(initial.ir);
const [nt, setNt] = useState<'4G' | '5G'>(initial.nt as '4G' | '5G'); const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
@ -43,14 +46,14 @@ export function SimFeatureToggles({
setVm(initial.vm); setVm(initial.vm);
setCw(initial.cw); setCw(initial.cw);
setIr(initial.ir); setIr(initial.ir);
setNt(initial.nt as '4G' | '5G'); setNt(initial.nt as "4G" | "5G");
}, [initial.vm, initial.cw, initial.ir, initial.nt]); }, [initial.vm, initial.cw, initial.ir, initial.nt]);
const reset = () => { const reset = () => {
setVm(initial.vm); setVm(initial.vm);
setCw(initial.cw); setCw(initial.cw);
setIr(initial.ir); setIr(initial.ir);
setNt(initial.nt as '4G' | '5G'); setNt(initial.nt as "4G" | "5G");
setError(null); setError(null);
setSuccess(null); setSuccess(null);
}; };
@ -67,13 +70,16 @@ export function SimFeatureToggles({
if (nt !== initial.nt) featurePayload.networkType = nt; if (nt !== initial.nt) featurePayload.networkType = nt;
if (Object.keys(featurePayload).length > 0) { if (Object.keys(featurePayload).length > 0) {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/features`, featurePayload); await authenticatedApi.post(
`/subscriptions/${subscriptionId}/sim/features`,
featurePayload
);
} }
setSuccess('Changes submitted successfully'); setSuccess("Changes submitted successfully");
onChanged?.(); onChanged?.();
} catch (e: any) { } catch (e: any) {
setError(e instanceof Error ? e.message : 'Failed to submit changes'); setError(e instanceof Error ? e.message : "Failed to submit changes");
} finally { } finally {
setLoading(false); setLoading(false);
setTimeout(() => setSuccess(null), 3000); setTimeout(() => setSuccess(null), 3000);
@ -82,18 +88,28 @@ export function SimFeatureToggles({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Service Options */} {/* Service Options */}
<div className={`${embedded ? '' : 'bg-white rounded-xl border border-gray-200 overflow-hidden'}`}> <div
className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 overflow-hidden"}`}
<div className={`${embedded ? '' : 'p-6'} space-y-6`}> >
<div className={`${embedded ? "" : "p-6"} space-y-6`}>
{/* Voice Mail */} {/* Voice Mail */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200"> <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-blue-100 rounded-lg p-2"> <div className="bg-blue-100 rounded-lg p-2">
<svg className="h-4 w-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /> className="h-4 w-4 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg> </svg>
</div> </div>
<div> <div>
@ -105,14 +121,14 @@ export function SimFeatureToggles({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-500">Current: </span> <span className="text-gray-500">Current: </span>
<span className={`font-medium ${initial.vm ? 'text-green-600' : 'text-gray-600'}`}> <span className={`font-medium ${initial.vm ? "text-green-600" : "text-gray-600"}`}>
{initial.vm ? 'Enabled' : 'Disabled'} {initial.vm ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="text-gray-400"></div> <div className="text-gray-400"></div>
<select <select
value={vm ? 'Enabled' : 'Disabled'} value={vm ? "Enabled" : "Disabled"}
onChange={(e) => setVm(e.target.value === 'Enabled')} onChange={e => setVm(e.target.value === "Enabled")}
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300" className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
> >
<option>Disabled</option> <option>Disabled</option>
@ -126,8 +142,18 @@ export function SimFeatureToggles({
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-purple-100 rounded-lg p-2"> <div className="bg-purple-100 rounded-lg p-2">
<svg className="h-4 w-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-4 w-4 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
</div> </div>
<div> <div>
@ -139,14 +165,14 @@ export function SimFeatureToggles({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-500">Current: </span> <span className="text-gray-500">Current: </span>
<span className={`font-medium ${initial.cw ? 'text-green-600' : 'text-gray-600'}`}> <span className={`font-medium ${initial.cw ? "text-green-600" : "text-gray-600"}`}>
{initial.cw ? 'Enabled' : 'Disabled'} {initial.cw ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="text-gray-400"></div> <div className="text-gray-400"></div>
<select <select
value={cw ? 'Enabled' : 'Disabled'} value={cw ? "Enabled" : "Disabled"}
onChange={(e) => setCw(e.target.value === 'Enabled')} onChange={e => setCw(e.target.value === "Enabled")}
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300" className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
> >
<option>Disabled</option> <option>Disabled</option>
@ -160,8 +186,18 @@ export function SimFeatureToggles({
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-green-100 rounded-lg p-2"> <div className="bg-green-100 rounded-lg p-2">
<svg className="h-4 w-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-4 w-4 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
</div> </div>
<div> <div>
@ -173,14 +209,14 @@ export function SimFeatureToggles({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="text-sm"> <div className="text-sm">
<span className="text-gray-500">Current: </span> <span className="text-gray-500">Current: </span>
<span className={`font-medium ${initial.ir ? 'text-green-600' : 'text-gray-600'}`}> <span className={`font-medium ${initial.ir ? "text-green-600" : "text-gray-600"}`}>
{initial.ir ? 'Enabled' : 'Disabled'} {initial.ir ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="text-gray-400"></div> <div className="text-gray-400"></div>
<select <select
value={ir ? 'Enabled' : 'Disabled'} value={ir ? "Enabled" : "Disabled"}
onChange={(e) => setIr(e.target.value === 'Enabled')} onChange={e => setIr(e.target.value === "Enabled")}
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300" className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
> >
<option>Disabled</option> <option>Disabled</option>
@ -194,8 +230,18 @@ export function SimFeatureToggles({
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-orange-100 rounded-lg p-2"> <div className="bg-orange-100 rounded-lg p-2">
<svg className="h-4 w-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" /> className="h-4 w-4 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
/>
</svg> </svg>
</div> </div>
<div> <div>
@ -212,7 +258,7 @@ export function SimFeatureToggles({
<div className="text-gray-400"></div> <div className="text-gray-400"></div>
<select <select
value={nt} value={nt}
onChange={(e) => setNt(e.target.value as '4G' | '5G')} onChange={e => setNt(e.target.value as "4G" | "5G")}
className="block w-20 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300" className="block w-20 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
> >
<option value="4G">4G</option> <option value="4G">4G</option>
@ -224,19 +270,34 @@ export function SimFeatureToggles({
</div> </div>
{/* Notes and Actions */} {/* Notes and Actions */}
<div className={`${embedded ? '' : 'bg-white rounded-xl border border-gray-200 p-6'}`}> <div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 p-6"}`}>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div className="flex items-start"> <div className="flex items-start">
<svg className="h-5 w-5 text-yellow-600 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-5 w-5 text-yellow-600 mt-0.5 mr-3 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<div className="space-y-2 text-sm text-yellow-800"> <div className="space-y-2 text-sm text-yellow-800">
<p><strong>Important Notes:</strong></p> <p>
<strong>Important Notes:</strong>
</p>
<ul className="list-disc list-inside space-y-1 ml-4"> <ul className="list-disc list-inside space-y-1 ml-4">
<li>Changes will take effect instantaneously (approx. 30min)</li> <li>Changes will take effect instantaneously (approx. 30min)</li>
<li>May require smartphone/device restart after changes are applied</li> <li>May require smartphone/device restart after changes are applied</li>
<li>5G requires a compatible smartphone/device. Will not function on 4G devices</li> <li>5G requires a compatible smartphone/device. Will not function on 4G devices</li>
<li>Changes to Voice Mail / Call Waiting must be requested before the 25th of the month</li> <li>
Changes to Voice Mail / Call Waiting must be requested before the 25th of the
month
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -245,8 +306,18 @@ export function SimFeatureToggles({
{success && ( {success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4"> <div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
<svg className="h-5 w-5 text-green-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-5 w-5 text-green-500 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<p className="text-sm font-medium text-green-800">{success}</p> <p className="text-sm font-medium text-green-800">{success}</p>
</div> </div>
@ -256,8 +327,18 @@ export function SimFeatureToggles({
{error && ( {error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4"> <div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
<svg className="h-5 w-5 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" /> className="h-5 w-5 text-red-500 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg> </svg>
<p className="text-sm font-medium text-red-800">{error}</p> <p className="text-sm font-medium text-red-800">{error}</p>
</div> </div>
@ -272,16 +353,36 @@ export function SimFeatureToggles({
> >
{loading ? ( {loading ? (
<> <>
<svg className="animate-spin -ml-1 mr-3 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"> <svg
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" className="opacity-25"></circle> className="animate-spin -ml-1 mr-3 h-4 w-4 text-white"
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" className="opacity-75"></path> fill="none"
viewBox="0 0 24 24"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
className="opacity-25"
></circle>
<path
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
className="opacity-75"
></path>
</svg> </svg>
Applying Changes... Applying Changes...
</> </>
) : ( ) : (
<> <>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg> </svg>
Apply Changes Apply Changes
</> </>
@ -293,7 +394,12 @@ export function SimFeatureToggles({
className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
> >
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg> </svg>
Reset Reset
</button> </button>

View File

@ -1,16 +1,16 @@
"use client"; "use client";
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { import {
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
ArrowPathIcon ArrowPathIcon,
} from '@heroicons/react/24/outline'; } from "@heroicons/react/24/outline";
import { SimDetailsCard, type SimDetails } from './SimDetailsCard'; import { SimDetailsCard, type SimDetails } from "./SimDetailsCard";
import { DataUsageChart, type SimUsage } from './DataUsageChart'; import { DataUsageChart, type SimUsage } from "./DataUsageChart";
import { SimActions } from './SimActions'; import { SimActions } from "./SimActions";
import { authenticatedApi } from '@/lib/api'; import { authenticatedApi } from "@/lib/api";
import { SimFeatureToggles } from './SimFeatureToggles'; import { SimFeatureToggles } from "./SimFeatureToggles";
interface SimManagementSectionProps { interface SimManagementSectionProps {
subscriptionId: number; subscriptionId: number;
@ -29,19 +29,19 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
const fetchSimInfo = async () => { const fetchSimInfo = async () => {
try { try {
setError(null); setError(null);
const data = await authenticatedApi.get<{ const data = await authenticatedApi.get<{
details: SimDetails; details: SimDetails;
usage: SimUsage; usage: SimUsage;
}>(`/subscriptions/${subscriptionId}/sim`); }>(`/subscriptions/${subscriptionId}/sim`);
setSimInfo(data); setSimInfo(data);
} catch (error: any) { } catch (error: any) {
if (error.status === 400) { if (error.status === 400) {
// Not a SIM subscription - this component shouldn't be shown // Not a SIM subscription - this component shouldn't be shown
setError('This subscription is not a SIM service'); setError("This subscription is not a SIM service");
} else { } else {
setError(error instanceof Error ? error.message : 'Failed to load SIM information'); setError(error instanceof Error ? error.message : "Failed to load SIM information");
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -105,7 +105,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6"> <div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6">
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" /> <ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
</div> </div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Unable to Load SIM Information</h3> <h3 className="text-xl font-semibold text-gray-900 mb-3">
Unable to Load SIM Information
</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p> <p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
<button <button
onClick={handleRefresh} onClick={handleRefresh}
@ -180,8 +182,18 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6"> <div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<div className="bg-blue-200 rounded-lg p-2 mr-3"> <div className="bg-blue-200 rounded-lg p-2 mr-3">
<svg className="h-5 w-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-5 w-5 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
</div> </div>
<h3 className="text-lg font-semibold text-blue-900">Important Information</h3> <h3 className="text-lg font-semibold text-blue-900">Important Information</h3>
@ -189,7 +201,8 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<ul className="space-y-2 text-sm text-blue-800"> <ul className="space-y-2 text-sm text-blue-800">
<li className="flex items-start"> <li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span> <span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
Data usage is updated in real-time and may take a few minutes to reflect recent activity Data usage is updated in real-time and may take a few minutes to reflect recent
activity
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span> <span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
@ -199,7 +212,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span> <span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
SIM cancellation is permanent and cannot be undone SIM cancellation is permanent and cannot be undone
</li> </li>
{simInfo.details.simType === 'esim' && ( {simInfo.details.simType === "esim" && (
<li className="flex items-start"> <li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span> <span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
eSIM profile reissue will provide a new QR code for activation eSIM profile reissue will provide a new QR code for activation

View File

@ -1,12 +1,8 @@
"use client"; "use client";
import React, { useState } from 'react'; import React, { useState } from "react";
import { import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
XMarkIcon, import { authenticatedApi } from "@/lib/api";
PlusIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline';
import { authenticatedApi } from '@/lib/api';
interface TopUpModalProps { interface TopUpModalProps {
subscriptionId: number; subscriptionId: number;
@ -16,7 +12,7 @@ interface TopUpModalProps {
} }
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) { export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
const [gbAmount, setGbAmount] = useState<string>('1'); const [gbAmount, setGbAmount] = useState<string>("1");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const getCurrentAmountMb = () => { const getCurrentAmountMb = () => {
@ -36,9 +32,9 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!isValidAmount()) { if (!isValidAmount()) {
onError('Please enter a whole number between 1 GB and 100 GB'); onError("Please enter a whole number between 1 GB and 100 GB");
return; return;
} }
@ -53,7 +49,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
onSuccess(); onSuccess();
} catch (error: any) { } catch (error: any) {
onError(error instanceof Error ? error.message : 'Failed to top up SIM'); onError(error instanceof Error ? error.message : "Failed to top up SIM");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -69,7 +65,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
<div className="fixed inset-0 z-50 overflow-y-auto" onClick={handleBackdropClick}> <div className="fixed inset-0 z-50 overflow-y-auto" onClick={handleBackdropClick}>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> <div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
{/* Header */} {/* Header */}
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
@ -94,14 +90,12 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{/* Amount Input */} {/* Amount Input */}
<div className="mb-6"> <div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
Amount (GB)
</label>
<div className="relative"> <div className="relative">
<input <input
type="number" type="number"
value={gbAmount} value={gbAmount}
onChange={(e) => setGbAmount(e.target.value)} onChange={e => setGbAmount(e.target.value)}
placeholder="Enter amount in GB" placeholder="Enter amount in GB"
min="1" min="1"
max="50" max="50"
@ -122,19 +116,15 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<div className="text-sm font-medium text-blue-900"> <div className="text-sm font-medium text-blue-900">
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'} {gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
</div>
<div className="text-xs text-blue-700">
= {getCurrentAmountMb()} MB
</div> </div>
<div className="text-xs text-blue-700">= {getCurrentAmountMb()} MB</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-lg font-bold text-blue-900"> <div className="text-lg font-bold text-blue-900">
¥{calculateCost().toLocaleString()} ¥{calculateCost().toLocaleString()}
</div> </div>
<div className="text-xs text-blue-700"> <div className="text-xs text-blue-700">(1GB = ¥500)</div>
(1GB = ¥500)
</div>
</div> </div>
</div> </div>
</div> </div>
@ -166,7 +156,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
disabled={loading || !isValidAmount()} disabled={loading || !isValidAmount()}
className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50" className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
> >
{loading ? 'Processing...' : `Top Up Now - ¥${calculateCost().toLocaleString()}`} {loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,9 +1,9 @@
export { SimManagementSection } from './components/SimManagementSection'; export { SimManagementSection } from "./components/SimManagementSection";
export { SimDetailsCard } from './components/SimDetailsCard'; export { SimDetailsCard } from "./components/SimDetailsCard";
export { DataUsageChart } from './components/DataUsageChart'; export { DataUsageChart } from "./components/DataUsageChart";
export { SimActions } from './components/SimActions'; export { SimActions } from "./components/SimActions";
export { TopUpModal } from './components/TopUpModal'; export { TopUpModal } from "./components/TopUpModal";
export { SimFeatureToggles } from './components/SimFeatureToggles'; export { SimFeatureToggles } from "./components/SimFeatureToggles";
export type { SimDetails } from './components/SimDetailsCard'; export type { SimDetails } from "./components/SimDetailsCard";
export type { SimUsage } from './components/DataUsageChart'; export type { SimUsage } from "./components/DataUsageChart";

View File

@ -19,9 +19,7 @@ export function QueryProvider({ children }: QueryProviderProps) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
{enableDevtools && ReactQueryDevtools ? ( {enableDevtools && ReactQueryDevtools ? <ReactQueryDevtools initialIsOpen={false} /> : null}
<ReactQueryDevtools initialIsOpen={false} />
) : null}
</QueryClientProvider> </QueryClientProvider>
); );
} }