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"));
await this.redis.setex(`blacklist:${token}`, defaultTtl, "1");
} catch (err) {
this.logger.warn("Failed to write token to Redis blacklist; proceeding without persistence", {
error: err instanceof Error ? err.message : String(err),
});
this.logger.warn(
"Failed to write token to Redis blacklist; proceeding without persistence",
{
error: err instanceof Error ? err.message : String(err),
}
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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