commit
2998a6afca
@ -154,3 +154,17 @@ enum AuditAction {
|
||||
MFA_DISABLED
|
||||
API_ACCESS
|
||||
}
|
||||
|
||||
// Per-SIM daily usage snapshot used to build full-month charts
|
||||
model SimUsageDaily {
|
||||
id Int @id @default(autoincrement())
|
||||
account String
|
||||
date DateTime @db.Date
|
||||
usageMb Float
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([account, date])
|
||||
@@index([account, date])
|
||||
@@map("sim_usage_daily")
|
||||
}
|
||||
|
||||
@ -62,6 +62,15 @@ export const envSchema = z.object({
|
||||
SENDGRID_SANDBOX: z.enum(["true", "false"]).default("false"),
|
||||
EMAIL_TEMPLATE_RESET: z.string().optional(),
|
||||
EMAIL_TEMPLATE_WELCOME: z.string().optional(),
|
||||
|
||||
// Freebit API Configuration
|
||||
FREEBIT_BASE_URL: z.string().url().default("https://i1.mvno.net/emptool/api"),
|
||||
FREEBIT_OEM_ID: z.string().default("PASI"),
|
||||
// Optional in schema so dev can boot without it; service warns/guards at runtime
|
||||
FREEBIT_OEM_KEY: z.string().optional(),
|
||||
FREEBIT_TIMEOUT: z.coerce.number().int().positive().default(30000),
|
||||
FREEBIT_RETRY_ATTEMPTS: z.coerce.number().int().positive().default(3),
|
||||
FREEBIT_DETAILS_ENDPOINT: z.string().default("/master/getAcnt/"),
|
||||
});
|
||||
|
||||
export function validateEnv(config: Record<string, unknown>): Record<string, unknown> {
|
||||
|
||||
@ -188,11 +188,13 @@ 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: {
|
||||
|
||||
@ -232,6 +232,8 @@ export class OrderOrchestrator {
|
||||
// Get order items for all orders in one query
|
||||
const orderIds = orders.map(o => `'${o.Id}'`).join(",");
|
||||
const itemsSoql = `
|
||||
|
||||
|
||||
SELECT Id, OrderId, Quantity,
|
||||
${getOrderItemProduct2Select()}
|
||||
FROM OrderItem
|
||||
@ -259,6 +261,9 @@ export class OrderOrchestrator {
|
||||
sku: String((p2?.[fields.product.sku] as string | undefined) || ""),
|
||||
itemClass: String((p2?.[fields.product.itemClass] as string | undefined) || ""),
|
||||
quantity: item.Quantity,
|
||||
unitPrice: item.UnitPrice,
|
||||
totalPrice: item.TotalPrice,
|
||||
billingCycle: String(item.PricebookEntry?.Product2?.Billing_Cycle__c || ""),
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
@ -269,6 +274,9 @@ export class OrderOrchestrator {
|
||||
sku?: string;
|
||||
itemClass?: string;
|
||||
quantity: number;
|
||||
unitPrice?: number;
|
||||
totalPrice?: number;
|
||||
billingCycle?: string;
|
||||
}>
|
||||
>
|
||||
);
|
||||
|
||||
489
apps/bff/src/subscriptions/sim-management.service.ts
Normal file
489
apps/bff/src/subscriptions/sim-management.service.ts
Normal file
@ -0,0 +1,489 @@
|
||||
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { FreebititService } from '../vendors/freebit/freebit.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;
|
||||
campaignCode?: string;
|
||||
expiryDate?: string; // YYYYMMDD
|
||||
scheduledAt?: string; // YYYYMMDD or YYYY-MM-DD HH:MM:SS
|
||||
}
|
||||
|
||||
export interface SimPlanChangeRequest {
|
||||
newPlanCode: string;
|
||||
assignGlobalIp?: boolean;
|
||||
scheduledAt?: string; // YYYYMMDD
|
||||
}
|
||||
|
||||
export interface SimCancelRequest {
|
||||
scheduledAt?: string; // YYYYMMDD - optional, immediate if omitted
|
||||
}
|
||||
|
||||
export interface SimTopUpHistoryRequest {
|
||||
fromDate: string; // YYYYMMDD
|
||||
toDate: string; // YYYYMMDD
|
||||
}
|
||||
|
||||
export interface SimFeaturesUpdateRequest {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: '4G' | '5G';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimManagementService {
|
||||
constructor(
|
||||
private readonly freebititService: FreebititService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly usageStore: SimUsageStoreService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Debug method to check subscription data for SIM services
|
||||
*/
|
||||
async debugSimSubscription(userId: string, subscriptionId: number): Promise<any> {
|
||||
try {
|
||||
const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId);
|
||||
|
||||
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'),
|
||||
groupName: subscription.groupName,
|
||||
status: subscription.status,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to debug subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a subscription is a SIM service
|
||||
*/
|
||||
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);
|
||||
|
||||
// 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');
|
||||
|
||||
if (!isSimService) {
|
||||
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 = '';
|
||||
|
||||
// 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) {
|
||||
const phoneFields = ['phone', 'msisdn', 'phonenumber', 'phone_number', 'mobile', 'sim_phone'];
|
||||
for (const fieldName of phoneFields) {
|
||||
if (subscription.customFields[fieldName]) {
|
||||
account = subscription.customFields[fieldName];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If still no account, check if subscription ID looks like a phone number
|
||||
if (!account && subscription.orderNumber) {
|
||||
const orderNum = subscription.orderNumber.toString();
|
||||
if (/^\d{10,11}$/.test(orderNum)) {
|
||||
account = orderNum;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Final fallback - for testing, use a dummy phone number based on subscription ID
|
||||
if (!account) {
|
||||
// Generate a test phone number: 080 + last 8 digits of subscription ID
|
||||
const subIdStr = subscriptionId.toString().padStart(8, '0');
|
||||
account = `080${subIdStr.slice(-8)}`;
|
||||
|
||||
this.logger.warn(`No SIM account identifier found for subscription ${subscriptionId}, using generated number: ${account}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
productName: subscription.productName,
|
||||
domain: subscription.domain,
|
||||
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up the account format (remove hyphens, spaces, etc.)
|
||||
account = account.replace(/[-\s()]/g, '');
|
||||
|
||||
// Validate phone number format (10-11 digits, optionally starting with +81)
|
||||
const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0
|
||||
if (!/^0\d{9,10}$/.test(cleanAccount)) {
|
||||
throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`);
|
||||
}
|
||||
|
||||
// Use the cleaned format
|
||||
account = cleanAccount;
|
||||
|
||||
return { account };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SIM details for a subscription
|
||||
*/
|
||||
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,
|
||||
account,
|
||||
status: simDetails.status,
|
||||
});
|
||||
|
||||
return simDetails;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get SIM details for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SIM data usage for a subscription
|
||||
*/
|
||||
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
|
||||
try {
|
||||
await this.usageStore.upsertToday(account, simUsage.todayUsageMb);
|
||||
await this.usageStore.cleanupPreviousMonths();
|
||||
const stored = await this.usageStore.getLastNDays(account, 30);
|
||||
if (stored.length > 0) {
|
||||
simUsage.recentDaysUsage = stored.map(d => ({
|
||||
date: d.date,
|
||||
usageKb: Math.round(d.usageMb * 1024),
|
||||
usageMb: d.usageMb,
|
||||
}));
|
||||
}
|
||||
} catch (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,
|
||||
account,
|
||||
todayUsageMb: simUsage.todayUsageMb,
|
||||
});
|
||||
|
||||
return simUsage;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get SIM usage for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Top up SIM data quota
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
// Validate date formats if provided
|
||||
if (request.expiryDate && !/^\d{8}$/.test(request.expiryDate)) {
|
||||
throw new BadRequestException('Expiry date must be in YYYYMMDD format');
|
||||
}
|
||||
|
||||
if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt.replace(/[-:\s]/g, ''))) {
|
||||
throw new BadRequestException('Scheduled date must be in YYYYMMDD format');
|
||||
}
|
||||
|
||||
await this.freebititService.topUpSim(account, request.quotaMb, {
|
||||
campaignCode: request.campaignCode,
|
||||
expiryDate: request.expiryDate,
|
||||
scheduledAt: request.scheduledAt,
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
quotaMb: request.quotaMb,
|
||||
scheduled: !!request.scheduledAt,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
quotaMb: request.quotaMb,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SIM top-up history
|
||||
*/
|
||||
async getSimTopUpHistory(
|
||||
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');
|
||||
}
|
||||
|
||||
const history = await this.freebititService.getSimTopUpHistory(
|
||||
account,
|
||||
request.fromDate,
|
||||
request.toDate
|
||||
);
|
||||
|
||||
this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
totalAdditions: history.totalAdditions,
|
||||
});
|
||||
|
||||
return history;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get SIM top-up history for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan
|
||||
*/
|
||||
async changeSimPlan(
|
||||
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');
|
||||
}
|
||||
|
||||
// Validate scheduled date if provided
|
||||
if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) {
|
||||
throw new BadRequestException('Scheduled date must be in YYYYMMDD format');
|
||||
}
|
||||
|
||||
const result = await this.freebititService.changeSimPlan(account, request.newPlanCode, {
|
||||
assignGlobalIp: request.assignGlobalIp,
|
||||
scheduledAt: request.scheduledAt,
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
newPlanCode: request.newPlanCode,
|
||||
scheduled: !!request.scheduledAt,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to change SIM plan for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
newPlanCode: request.newPlanCode,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SIM features (voicemail, call waiting, roaming, network type)
|
||||
*/
|
||||
async updateSimFeatures(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimFeaturesUpdateRequest
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||
|
||||
// Validate network type if provided
|
||||
if (request.networkType && !['4G', '5G'].includes(request.networkType)) {
|
||||
throw new BadRequestException('networkType must be either "4G" or "5G"');
|
||||
}
|
||||
|
||||
await this.freebititService.updateSimFeatures(account, request);
|
||||
|
||||
this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
...request,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update SIM features for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
...request,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
await this.freebititService.cancelSim(account, request.scheduledAt);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
scheduled: !!request.scheduledAt,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to cancel SIM for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue eSIM profile
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
await this.freebititService.reissueEsimProfile(account);
|
||||
|
||||
this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to reissue eSIM profile for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive SIM information (details + usage combined)
|
||||
*/
|
||||
async getSimInfo(userId: string, subscriptionId: number): Promise<{
|
||||
details: SimDetails;
|
||||
usage: SimUsage;
|
||||
}> {
|
||||
try {
|
||||
const [details, usage] = await Promise.all([
|
||||
this.getSimDetails(userId, subscriptionId),
|
||||
this.getSimUsage(userId, subscriptionId),
|
||||
]);
|
||||
|
||||
// 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 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) {
|
||||
const capMb = capGb * 1024;
|
||||
const remainingMb = Math.max(capMb - usedMb, 0);
|
||||
details.remainingQuotaMb = Math.round(remainingMb * 100) / 100;
|
||||
details.remainingQuotaKb = Math.round(details.remainingQuotaMb * 1024);
|
||||
}
|
||||
}
|
||||
|
||||
return { details, usage };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get comprehensive SIM info for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
apps/bff/src/subscriptions/sim-usage-store.service.ts
Normal file
49
apps/bff/src/subscriptions/sim-usage-store.service.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { PrismaService } from "../common/prisma/prisma.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class SimUsageStoreService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@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');
|
||||
}
|
||||
|
||||
async upsertToday(account: string, usageMb: number, date?: Date): Promise<void> {
|
||||
const day = this.normalizeDate(date);
|
||||
try {
|
||||
await (this.prisma as any).simUsageDaily.upsert({
|
||||
where: { account_date: { account, date: day } as any },
|
||||
update: { usageMb },
|
||||
create: { account, date: day, usageMb },
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.logger.error("Failed to upsert daily usage", { account, error: e?.message });
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
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 }));
|
||||
}
|
||||
|
||||
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 } } });
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
BadRequestException,
|
||||
@ -14,8 +16,10 @@ import {
|
||||
ApiQuery,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiBody,
|
||||
} from "@nestjs/swagger";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { SimManagementService } from "./sim-management.service";
|
||||
|
||||
import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/shared";
|
||||
import type { RequestWithUser } from "../auth/auth.types";
|
||||
@ -24,7 +28,10 @@ import type { RequestWithUser } from "../auth/auth.types";
|
||||
@Controller("subscriptions")
|
||||
@ApiBearerAuth()
|
||||
export class SubscriptionsController {
|
||||
constructor(private readonly subscriptionsService: SubscriptionsService) {}
|
||||
constructor(
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
private readonly simManagementService: SimManagementService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
@ -184,4 +191,235 @@ export class SubscriptionsController {
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ==================== SIM Management Endpoints ====================
|
||||
|
||||
@Get(":id/sim/debug")
|
||||
@ApiOperation({
|
||||
summary: "Debug SIM subscription data",
|
||||
description: "Retrieves subscription data to help debug SIM management issues",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiResponse({ status: 200, description: "Subscription debug data" })
|
||||
async debugSimSubscription(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
return this.simManagementService.debugSimSubscription(req.user.id, subscriptionId);
|
||||
}
|
||||
|
||||
@Get(":id/sim")
|
||||
@ApiOperation({
|
||||
summary: "Get SIM details and usage",
|
||||
description: "Retrieves comprehensive SIM information including details and current usage",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiResponse({ status: 200, description: "SIM information" })
|
||||
@ApiResponse({ status: 400, description: "Not a SIM subscription" })
|
||||
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||
async getSimInfo(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
return this.simManagementService.getSimInfo(req.user.id, subscriptionId);
|
||||
}
|
||||
|
||||
@Get(":id/sim/details")
|
||||
@ApiOperation({
|
||||
summary: "Get SIM details",
|
||||
description: "Retrieves detailed SIM information including ICCID, plan, status, etc.",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiResponse({ status: 200, description: "SIM details" })
|
||||
async getSimDetails(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
return this.simManagementService.getSimDetails(req.user.id, subscriptionId);
|
||||
}
|
||||
|
||||
@Get(":id/sim/usage")
|
||||
@ApiOperation({
|
||||
summary: "Get SIM data usage",
|
||||
description: "Retrieves current data usage and recent usage history",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiResponse({ status: 200, description: "SIM usage data" })
|
||||
async getSimUsage(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
return this.simManagementService.getSimUsage(req.user.id, subscriptionId);
|
||||
}
|
||||
|
||||
@Get(":id/sim/top-up-history")
|
||||
@ApiOperation({
|
||||
summary: "Get SIM top-up history",
|
||||
description: "Retrieves data top-up history for the specified date range",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiQuery({ name: "fromDate", description: "Start date (YYYYMMDD)", example: "20240101" })
|
||||
@ApiQuery({ name: "toDate", description: "End date (YYYYMMDD)", example: "20241231" })
|
||||
@ApiResponse({ status: 200, description: "Top-up history" })
|
||||
async getSimTopUpHistory(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("fromDate") fromDate: string,
|
||||
@Query("toDate") toDate: string
|
||||
) {
|
||||
if (!fromDate || !toDate) {
|
||||
throw new BadRequestException("fromDate and toDate are required");
|
||||
}
|
||||
|
||||
return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, {
|
||||
fromDate,
|
||||
toDate,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":id/sim/top-up")
|
||||
@ApiOperation({
|
||||
summary: "Top up SIM data quota",
|
||||
description: "Add data quota to the SIM service",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiBody({
|
||||
description: "Top-up request",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
quotaMb: { type: "number", description: "Quota in MB", example: 1000 },
|
||||
campaignCode: { type: "string", description: "Optional campaign code" },
|
||||
expiryDate: { type: "string", description: "Expiry date (YYYYMMDD)", example: "20241231" },
|
||||
scheduledAt: { type: "string", description: "Schedule for later (YYYYMMDD)", example: "20241225" },
|
||||
},
|
||||
required: ["quotaMb"],
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Top-up successful" })
|
||||
async topUpSim(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: {
|
||||
quotaMb: number;
|
||||
campaignCode?: string;
|
||||
expiryDate?: string;
|
||||
scheduledAt?: string;
|
||||
}
|
||||
) {
|
||||
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
|
||||
return { success: true, message: "SIM top-up completed successfully" };
|
||||
}
|
||||
|
||||
@Post(":id/sim/change-plan")
|
||||
@ApiOperation({
|
||||
summary: "Change SIM plan",
|
||||
description: "Change the SIM service plan",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiBody({
|
||||
description: "Plan change request",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
newPlanCode: { type: "string", description: "New plan code", example: "LTE3G_P01" },
|
||||
assignGlobalIp: { type: "boolean", description: "Assign global IP address" },
|
||||
scheduledAt: { type: "string", description: "Schedule for later (YYYYMMDD)", example: "20241225" },
|
||||
},
|
||||
required: ["newPlanCode"],
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Plan change successful" })
|
||||
async changeSimPlan(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: {
|
||||
newPlanCode: string;
|
||||
assignGlobalIp?: boolean;
|
||||
scheduledAt?: string;
|
||||
}
|
||||
) {
|
||||
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
|
||||
return {
|
||||
success: true,
|
||||
message: "SIM plan change completed successfully",
|
||||
...result
|
||||
};
|
||||
}
|
||||
|
||||
@Post(":id/sim/cancel")
|
||||
@ApiOperation({
|
||||
summary: "Cancel SIM service",
|
||||
description: "Cancel the SIM service (immediate or scheduled)",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiBody({
|
||||
description: "Cancellation request",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
scheduledAt: { type: "string", description: "Schedule cancellation (YYYYMMDD)", example: "20241231" },
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Cancellation successful" })
|
||||
async cancelSim(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: { scheduledAt?: string } = {}
|
||||
) {
|
||||
await this.simManagementService.cancelSim(req.user.id, subscriptionId, body);
|
||||
return { success: true, message: "SIM cancellation completed successfully" };
|
||||
}
|
||||
|
||||
@Post(":id/sim/reissue-esim")
|
||||
@ApiOperation({
|
||||
summary: "Reissue eSIM profile",
|
||||
description: "Reissue a downloadable eSIM profile (eSIM only)",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiResponse({ status: 200, description: "eSIM reissue successful" })
|
||||
@ApiResponse({ status: 400, description: "Not an eSIM subscription" })
|
||||
async reissueEsimProfile(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
) {
|
||||
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId);
|
||||
return { success: true, message: "eSIM profile reissue completed successfully" };
|
||||
}
|
||||
|
||||
@Post(":id/sim/features")
|
||||
@ApiOperation({
|
||||
summary: "Update SIM features",
|
||||
description: "Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)",
|
||||
})
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiBody({
|
||||
description: "Features update request",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
voiceMailEnabled: { type: "boolean" },
|
||||
callWaitingEnabled: { type: "boolean" },
|
||||
internationalRoamingEnabled: { type: "boolean" },
|
||||
networkType: { type: "string", enum: ["4G", "5G"] },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Features update successful" })
|
||||
async updateSimFeatures(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body()
|
||||
body: {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: '4G' | '5G';
|
||||
}
|
||||
) {
|
||||
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
||||
return { success: true, message: "SIM features updated successfully" };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SubscriptionsController } from "./subscriptions.controller";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { SimManagementService } from "./sim-management.service";
|
||||
import { SimUsageStoreService } from "./sim-usage-store.service";
|
||||
import { WhmcsModule } from "../vendors/whmcs/whmcs.module";
|
||||
import { MappingsModule } from "../mappings/mappings.module";
|
||||
import { FreebititModule } from "../vendors/freebit/freebit.module";
|
||||
|
||||
@Module({
|
||||
imports: [WhmcsModule, MappingsModule],
|
||||
imports: [WhmcsModule, MappingsModule, FreebititModule],
|
||||
controllers: [SubscriptionsController],
|
||||
providers: [SubscriptionsService],
|
||||
providers: [SubscriptionsService, SimManagementService, SimUsageStoreService],
|
||||
})
|
||||
export class SubscriptionsModule {}
|
||||
|
||||
8
apps/bff/src/vendors/freebit/freebit.module.ts
vendored
Normal file
8
apps/bff/src/vendors/freebit/freebit.module.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FreebititService } from './freebit.service';
|
||||
|
||||
@Module({
|
||||
providers: [FreebititService],
|
||||
exports: [FreebititService],
|
||||
})
|
||||
export class FreebititModule {}
|
||||
662
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
Normal file
662
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
Normal file
@ -0,0 +1,662 @@
|
||||
import { Injectable, Inject, BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import {
|
||||
FreebititConfig,
|
||||
FreebititAuthRequest,
|
||||
FreebititAuthResponse,
|
||||
FreebititAccountDetailsRequest,
|
||||
FreebititAccountDetailsResponse,
|
||||
FreebititTrafficInfoRequest,
|
||||
FreebititTrafficInfoResponse,
|
||||
FreebititTopUpRequest,
|
||||
FreebititTopUpResponse,
|
||||
FreebititQuotaHistoryRequest,
|
||||
FreebititQuotaHistoryResponse,
|
||||
FreebititPlanChangeRequest,
|
||||
FreebititPlanChangeResponse,
|
||||
FreebititCancelPlanRequest,
|
||||
FreebititCancelPlanResponse,
|
||||
FreebititEsimReissueRequest,
|
||||
FreebititEsimReissueResponse,
|
||||
FreebititEsimAddAccountRequest,
|
||||
FreebititEsimAddAccountResponse,
|
||||
SimDetails,
|
||||
SimUsage,
|
||||
SimTopUpHistory,
|
||||
FreebititError,
|
||||
FreebititAddSpecRequest,
|
||||
FreebititAddSpecResponse
|
||||
} from './interfaces/freebit.types';
|
||||
|
||||
@Injectable()
|
||||
export class FreebititService {
|
||||
private readonly config: FreebititConfig;
|
||||
private authKeyCache: {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
) {
|
||||
this.config = {
|
||||
baseUrl: this.configService.get<string>('FREEBIT_BASE_URL') || 'https://i1.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.debug('Freebit service initialized', {
|
||||
baseUrl: this.config.baseUrl,
|
||||
oemId: this.config.oemId,
|
||||
hasOemKey: !!this.config.oemKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Freebit SIM status to portal status
|
||||
*/
|
||||
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';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or refresh authentication token
|
||||
*/
|
||||
private async getAuthKey(): Promise<string> {
|
||||
// Check if we have a valid cached token
|
||||
if (this.authKeyCache && this.authKeyCache.expiresAt > Date.now()) {
|
||||
return this.authKeyCache.token;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if configuration is available
|
||||
if (!this.config.oemKey) {
|
||||
throw new Error('Freebit API not configured: FREEBIT_OEM_KEY is missing');
|
||||
}
|
||||
|
||||
const request: FreebititAuthRequest = {
|
||||
oemId: this.config.oemId,
|
||||
oemKey: this.config.oemKey,
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `json=${JSON.stringify(request)}`,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as FreebititAuthResponse;
|
||||
|
||||
if (data.resultCode !== '100') {
|
||||
throw new FreebititErrorImpl(
|
||||
`Authentication failed: ${data.status.message}`,
|
||||
data.resultCode,
|
||||
data.status.statusCode,
|
||||
data.status.message
|
||||
);
|
||||
}
|
||||
|
||||
// Cache the token for 50 minutes (assuming 60min expiry)
|
||||
this.authKeyCache = {
|
||||
token: data.authKey,
|
||||
expiresAt: Date.now() + 50 * 60 * 1000,
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make authenticated API request with error handling
|
||||
*/
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `json=${JSON.stringify(requestData)}`,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let bodySnippet: string | undefined;
|
||||
try {
|
||||
const text = await response.text();
|
||||
bodySnippet = text ? text.slice(0, 500) : undefined;
|
||||
} catch {}
|
||||
this.logger.error('Freebit API non-OK response', {
|
||||
endpoint,
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: bodySnippet,
|
||||
});
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json() as T;
|
||||
|
||||
// Check for API-level errors
|
||||
if (responseData && (responseData as any).resultCode !== '100') {
|
||||
const errorData = responseData as any;
|
||||
throw new FreebititErrorImpl(
|
||||
`API Error: ${errorData.status?.message || 'Unknown error'}`,
|
||||
errorData.resultCode,
|
||||
errorData.status?.statusCode,
|
||||
errorData.status?.message
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug('Freebit API Request Success', {
|
||||
endpoint,
|
||||
resultCode: (responseData as any).resultCode,
|
||||
});
|
||||
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed SIM account information
|
||||
*/
|
||||
async getSimDetails(account: string): Promise<SimDetails> {
|
||||
try {
|
||||
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',
|
||||
]));
|
||||
|
||||
let response: FreebititAccountDetailsResponse | undefined;
|
||||
let lastError: any;
|
||||
for (const ep of candidates) {
|
||||
try {
|
||||
if (ep !== candidates[0]) {
|
||||
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
||||
}
|
||||
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(ep, request);
|
||||
break; // success
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
if (typeof err?.message === 'string' && err.message.includes('HTTP 404')) {
|
||||
// try next candidate
|
||||
continue;
|
||||
}
|
||||
// non-404 error, rethrow
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
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] : []);
|
||||
if (!list.length) {
|
||||
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 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 simDetails: SimDetails = {
|
||||
account: String(simData.account ?? account),
|
||||
msisdn: String(simData.account ?? account),
|
||||
iccid: simData.iccid ? String(simData.iccid) : undefined,
|
||||
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',
|
||||
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 / 1024) * 100) / 100 : 0,
|
||||
startDate,
|
||||
ipv4: simData.ipv4,
|
||||
ipv6: simData.ipv6,
|
||||
voiceMailEnabled: simData.voicemail === 10 || simData.voiceMail === 10,
|
||||
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,
|
||||
};
|
||||
|
||||
this.logger.log(`Retrieved SIM details for account ${account}`, {
|
||||
account,
|
||||
status: simDetails.status,
|
||||
planCode: simDetails.planCode,
|
||||
});
|
||||
|
||||
return simDetails;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to get SIM details for account ${account}`, { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SIM data usage information
|
||||
*/
|
||||
async getSimUsage(account: string): Promise<SimUsage> {
|
||||
try {
|
||||
const request: Omit<FreebititTrafficInfoRequest, 'authKey'> = { account };
|
||||
|
||||
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
|
||||
'/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],
|
||||
usageKb: parseInt(usage, 10) || 0,
|
||||
usageMb: Math.round(parseInt(usage, 10) / 1024 * 100) / 100,
|
||||
}));
|
||||
|
||||
const simUsage: SimUsage = {
|
||||
account,
|
||||
todayUsageKb,
|
||||
todayUsageMb: Math.round(todayUsageKb / 1024 * 100) / 100,
|
||||
recentDaysUsage: recentDaysData,
|
||||
isBlacklisted: response.traffic.blackList === '10',
|
||||
};
|
||||
|
||||
this.logger.log(`Retrieved SIM usage for account ${account}`, {
|
||||
account,
|
||||
todayUsageMb: simUsage.todayUsageMb,
|
||||
isBlacklisted: simUsage.isBlacklisted,
|
||||
});
|
||||
|
||||
return simUsage;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to get SIM usage for account ${account}`, { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Top up SIM data quota
|
||||
*/
|
||||
async topUpSim(account: string, quotaMb: number, options: {
|
||||
campaignCode?: string;
|
||||
expiryDate?: string;
|
||||
scheduledAt?: string;
|
||||
} = {}): Promise<void> {
|
||||
try {
|
||||
const quotaKb = quotaMb * 1024;
|
||||
|
||||
const request: Omit<FreebititTopUpRequest, 'authKey'> = {
|
||||
account,
|
||||
quota: quotaKb,
|
||||
quotaCode: options.campaignCode,
|
||||
expire: options.expiryDate,
|
||||
};
|
||||
|
||||
// Use PA05-22 for scheduled top-ups, PA04-04 for immediate
|
||||
const endpoint = options.scheduledAt ? '/mvno/eachQuota/' : '/master/addSpec/';
|
||||
|
||||
if (options.scheduledAt && endpoint === '/mvno/eachQuota/') {
|
||||
(request as any).runTime = options.scheduledAt;
|
||||
}
|
||||
|
||||
await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request);
|
||||
|
||||
this.logger.log(`Successfully topped up SIM ${account}`, {
|
||||
account,
|
||||
quotaMb,
|
||||
quotaKb,
|
||||
campaignCode: options.campaignCode,
|
||||
scheduled: !!options.scheduledAt,
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to top up SIM ${account}`, {
|
||||
error: error.message,
|
||||
account,
|
||||
quotaMb,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SIM top-up history
|
||||
*/
|
||||
async getSimTopUpHistory(account: string, fromDate: string, toDate: string): Promise<SimTopUpHistory> {
|
||||
try {
|
||||
const request: Omit<FreebititQuotaHistoryRequest, 'authKey'> = {
|
||||
account,
|
||||
fromDate,
|
||||
toDate,
|
||||
};
|
||||
|
||||
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
|
||||
'/mvno/getQuotaHistory/',
|
||||
request
|
||||
);
|
||||
|
||||
const history: SimTopUpHistory = {
|
||||
account,
|
||||
totalAdditions: response.total,
|
||||
additionCount: response.count,
|
||||
history: response.quotaHistory.map(item => ({
|
||||
quotaKb: parseInt(item.quota, 10),
|
||||
quotaMb: Math.round(parseInt(item.quota, 10) / 1024 * 100) / 100,
|
||||
addedDate: item.date,
|
||||
expiryDate: item.expire,
|
||||
campaignCode: item.quotaCode,
|
||||
})),
|
||||
};
|
||||
|
||||
this.logger.log(`Retrieved SIM top-up history for account ${account}`, {
|
||||
account,
|
||||
totalAdditions: history.totalAdditions,
|
||||
additionCount: history.additionCount,
|
||||
});
|
||||
|
||||
return history;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to get SIM top-up history for account ${account}`, { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change SIM plan
|
||||
*/
|
||||
async changeSimPlan(account: string, newPlanCode: string, options: {
|
||||
assignGlobalIp?: boolean;
|
||||
scheduledAt?: string;
|
||||
} = {}): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||
try {
|
||||
const request: Omit<FreebititPlanChangeRequest, 'authKey'> = {
|
||||
account,
|
||||
plancode: newPlanCode,
|
||||
globalip: options.assignGlobalIp ? '1' : '0',
|
||||
runTime: options.scheduledAt,
|
||||
};
|
||||
|
||||
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
|
||||
'/mvno/changePlan/',
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully changed SIM plan for account ${account}`, {
|
||||
account,
|
||||
newPlanCode,
|
||||
assignGlobalIp: options.assignGlobalIp,
|
||||
scheduled: !!options.scheduledAt,
|
||||
});
|
||||
|
||||
return {
|
||||
ipv4: response.ipv4,
|
||||
ipv6: response.ipv6,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to change SIM plan for account ${account}`, {
|
||||
error: error.message,
|
||||
account,
|
||||
newPlanCode,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
try {
|
||||
const request: Omit<FreebititAddSpecRequest, 'authKey'> = {
|
||||
account,
|
||||
};
|
||||
|
||||
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;
|
||||
request.callwaiting = request.callWaiting;
|
||||
}
|
||||
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);
|
||||
|
||||
this.logger.log(`Updated SIM features for account ${account}`, {
|
||||
account,
|
||||
voiceMailEnabled: features.voiceMailEnabled,
|
||||
callWaitingEnabled: features.callWaitingEnabled,
|
||||
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||
networkType: features.networkType,
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to update SIM features for account ${account}`, {
|
||||
error: error.message,
|
||||
account,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebititCancelPlanRequest, 'authKey'> = {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
};
|
||||
|
||||
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
|
||||
'/mvno/releasePlan/',
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
||||
account,
|
||||
scheduled: !!scheduledAt,
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
||||
error: error.message,
|
||||
account,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue eSIM profile using reissueProfile endpoint
|
||||
*/
|
||||
async reissueEsimProfile(account: string): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebititEsimReissueRequest, 'authKey'> = { account };
|
||||
|
||||
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>(
|
||||
'/esim/reissueProfile/',
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully reissued eSIM profile for account ${account}`, { account });
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, {
|
||||
error: error.message,
|
||||
account,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation)
|
||||
*/
|
||||
async reissueEsimProfileEnhanced(
|
||||
account: string,
|
||||
newEid: string,
|
||||
options: {
|
||||
oldProductNumber?: string;
|
||||
oldEid?: string;
|
||||
planCode?: string;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebititEsimAddAccountRequest, 'authKey'> = {
|
||||
aladinOperated: '20',
|
||||
account,
|
||||
eid: newEid,
|
||||
addKind: 'R', // R = reissue
|
||||
reissue: {
|
||||
oldProductNumber: options.oldProductNumber,
|
||||
oldEid: options.oldEid,
|
||||
},
|
||||
};
|
||||
|
||||
// Add optional fields
|
||||
if (options.planCode) {
|
||||
request.planCode = options.planCode;
|
||||
}
|
||||
|
||||
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
|
||||
'/mvno/esim/addAcnt/',
|
||||
request
|
||||
);
|
||||
|
||||
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}`, {
|
||||
error: error.message,
|
||||
account,
|
||||
newEid,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for Freebit API
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.getAuthKey();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Freebit API health check failed', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom error class for Freebit API errors
|
||||
class FreebititErrorImpl extends Error {
|
||||
public readonly resultCode: string;
|
||||
public readonly statusCode: string;
|
||||
public readonly freebititMessage: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
resultCode: string,
|
||||
statusCode: string,
|
||||
freebititMessage: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'FreebititError';
|
||||
this.resultCode = resultCode;
|
||||
this.statusCode = statusCode;
|
||||
this.freebititMessage = freebititMessage;
|
||||
}
|
||||
}
|
||||
302
apps/bff/src/vendors/freebit/interfaces/freebit.types.ts
vendored
Normal file
302
apps/bff/src/vendors/freebit/interfaces/freebit.types.ts
vendored
Normal file
@ -0,0 +1,302 @@
|
||||
// Freebit API Type Definitions
|
||||
|
||||
export interface FreebititAuthRequest {
|
||||
oemId: string; // 4-char alphanumeric ISP identifier
|
||||
oemKey: string; // 32-char auth key
|
||||
}
|
||||
|
||||
export interface FreebititAuthResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
authKey: string; // Token for subsequent API calls
|
||||
}
|
||||
|
||||
export interface FreebititAccountDetailsRequest {
|
||||
authKey: string;
|
||||
version?: string | number; // Docs recommend "2"
|
||||
requestDatas: Array<{
|
||||
kind: 'MASTER' | 'MVNO' | string;
|
||||
account?: string | number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FreebititAccountDetailsResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string | number;
|
||||
};
|
||||
masterAccount?: string;
|
||||
// Docs show this can be an array (MASTER + MVNO) or a single object for MVNO
|
||||
responseDatas:
|
||||
| {
|
||||
kind: 'MASTER' | 'MVNO' | string;
|
||||
account: string | number;
|
||||
state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string;
|
||||
startDate?: string | number;
|
||||
relationCode?: string;
|
||||
resultCode?: string | number;
|
||||
planCode?: string;
|
||||
iccid?: string | number;
|
||||
imsi?: string | number;
|
||||
eid?: string;
|
||||
contractLine?: string;
|
||||
size?: 'standard' | 'nano' | 'micro' | 'esim' | string;
|
||||
sms?: number; // 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;
|
||||
date: string | number;
|
||||
};
|
||||
}
|
||||
| Array<{
|
||||
kind: 'MASTER' | 'MVNO' | string;
|
||||
account: string | number;
|
||||
state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string;
|
||||
startDate?: string | number;
|
||||
relationCode?: string;
|
||||
resultCode?: string | number;
|
||||
planCode?: string;
|
||||
iccid?: string | number;
|
||||
imsi?: string | number;
|
||||
eid?: string;
|
||||
contractLine?: string;
|
||||
size?: 'standard' | 'nano' | 'micro' | 'esim' | string;
|
||||
sms?: number;
|
||||
talk?: number;
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
quota?: number;
|
||||
async?: {
|
||||
func: 'regist' | 'stop' | 'resume' | 'cancel' | 'pinset' | 'pinunset' | string;
|
||||
date: string | number;
|
||||
};
|
||||
}>
|
||||
}
|
||||
|
||||
export interface FreebititTrafficInfoRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
export interface FreebititTrafficInfoResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
account: string;
|
||||
traffic: {
|
||||
today: string; // Today's usage in KB
|
||||
inRecentDays: string; // Comma-separated recent days usage
|
||||
blackList: string; // 10=blacklisted, 20=not blacklisted
|
||||
};
|
||||
}
|
||||
|
||||
export interface FreebititTopUpRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
quota: number; // KB units (e.g., 102400 for 100MB)
|
||||
quotaCode?: string; // Campaign code
|
||||
expire?: string; // YYYYMMDD format
|
||||
}
|
||||
|
||||
export interface FreebititTopUpResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
// AddSpec request for updating SIM options/features immediately
|
||||
export interface FreebititAddSpecRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
// Feature flags: 10 = enabled, 20 = disabled
|
||||
voiceMail?: '10' | '20';
|
||||
voicemail?: '10' | '20';
|
||||
callWaiting?: '10' | '20';
|
||||
callwaiting?: '10' | '20';
|
||||
worldWing?: '10' | '20';
|
||||
worldwing?: '10' | '20';
|
||||
contractLine?: string; // '4G' or '5G'
|
||||
}
|
||||
|
||||
export interface FreebititAddSpecResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FreebititQuotaHistoryRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
fromDate: string; // YYYYMMDD
|
||||
toDate: string; // YYYYMMDD
|
||||
}
|
||||
|
||||
export interface FreebititQuotaHistoryResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
account: string;
|
||||
total: number;
|
||||
count: number;
|
||||
quotaHistory: Array<{
|
||||
quota: string;
|
||||
expire: string;
|
||||
date: string;
|
||||
quotaCode: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export interface FreebititPlanChangeResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
}
|
||||
|
||||
export interface FreebititCancelPlanRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
||||
}
|
||||
|
||||
export interface FreebititCancelPlanResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FreebititEsimReissueRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
export interface FreebititEsimReissueResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FreebititEsimAddAccountRequest {
|
||||
authKey: string;
|
||||
aladinOperated?: string;
|
||||
account: string;
|
||||
eid: string;
|
||||
addKind: 'N' | 'R'; // N = new, R = reissue
|
||||
createType?: string;
|
||||
simKind?: string;
|
||||
planCode?: string;
|
||||
contractLine?: string;
|
||||
reissue?: {
|
||||
oldProductNumber?: string;
|
||||
oldEid?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FreebititEsimAddAccountResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Portal-specific types for SIM management
|
||||
export interface SimDetails {
|
||||
account: string;
|
||||
msisdn: string;
|
||||
iccid?: string;
|
||||
imsi?: string;
|
||||
eid?: string;
|
||||
planCode: string;
|
||||
status: 'active' | 'suspended' | 'cancelled' | 'pending';
|
||||
simType: 'physical' | 'esim';
|
||||
size: 'standard' | 'nano' | 'micro' | 'esim';
|
||||
hasVoice: boolean;
|
||||
hasSms: boolean;
|
||||
remainingQuotaKb: number;
|
||||
remainingQuotaMb: number;
|
||||
startDate?: string;
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
// Optional extended service features
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: string; // e.g., '4G' or '5G'
|
||||
pendingOperations?: Array<{
|
||||
operation: string;
|
||||
scheduledDate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SimUsage {
|
||||
account: string;
|
||||
todayUsageKb: number;
|
||||
todayUsageMb: number;
|
||||
recentDaysUsage: Array<{
|
||||
date: string;
|
||||
usageKb: number;
|
||||
usageMb: number;
|
||||
}>;
|
||||
isBlacklisted: boolean;
|
||||
}
|
||||
|
||||
export interface SimTopUpHistory {
|
||||
account: string;
|
||||
totalAdditions: number;
|
||||
additionCount: number;
|
||||
history: Array<{
|
||||
quotaKb: number;
|
||||
quotaMb: number;
|
||||
addedDate: string;
|
||||
expiryDate?: string;
|
||||
campaignCode?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Error handling
|
||||
export interface FreebititError extends Error {
|
||||
resultCode: string;
|
||||
statusCode: string;
|
||||
freebititMessage: string;
|
||||
}
|
||||
|
||||
// Configuration
|
||||
export interface FreebititConfig {
|
||||
baseUrl: string;
|
||||
oemId: string;
|
||||
oemKey: string;
|
||||
timeout: number;
|
||||
retryAttempts: number;
|
||||
detailsEndpoint?: string;
|
||||
}
|
||||
@ -9,7 +9,6 @@ import {
|
||||
ArrowRightIcon,
|
||||
WifiIcon,
|
||||
GlobeAltIcon,
|
||||
SignalIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { AnimatedCard } from "@/components/catalog/animated-card";
|
||||
import { AnimatedButton } from "@/components/catalog/animated-button";
|
||||
@ -32,7 +31,7 @@ export default function CatalogPage() {
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-4xl mx-auto leading-relaxed">
|
||||
Discover high-speed internet, flexible mobile plans, and secure VPN services. Each
|
||||
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>
|
||||
@ -57,13 +56,13 @@ export default function CatalogPage() {
|
||||
{/* SIM/eSIM Service */}
|
||||
<ServiceHeroCard
|
||||
title="SIM & eSIM"
|
||||
description="Flexible mobile data and voice plans with both physical SIM and eSIM options. Family discounts available."
|
||||
description="Wide range of data options and voice plans with both physical SIM and eSIM options. Family discounts available."
|
||||
icon={<DevicePhoneMobileIcon className="h-12 w-12" />}
|
||||
features={[
|
||||
"Physical SIM & eSIM",
|
||||
"Data + Voice plans",
|
||||
"Data + SMS/Voice plans",
|
||||
"Family discounts",
|
||||
"Flexible data sizes",
|
||||
"Multiple data options",
|
||||
]}
|
||||
href="/catalog/sim"
|
||||
color="green"
|
||||
@ -95,17 +94,12 @@ export default function CatalogPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<FeatureCard
|
||||
icon={<WifiIcon className="h-10 w-10 text-blue-600" />}
|
||||
title="Location-Based Plans"
|
||||
description="Internet plans tailored to your house type and available infrastructure"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<SignalIcon className="h-10 w-10 text-green-600" />}
|
||||
title="Smart Recommendations"
|
||||
description="Personalized plan suggestions based on your account and usage patterns"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<GlobeAltIcon className="h-10 w-10 text-purple-600" />}
|
||||
title="Seamless Integration"
|
||||
|
||||
@ -45,7 +45,7 @@ function PlanTypeSection({
|
||||
const familyPlans = plans.filter(p => p.hasFamilyDiscount);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
{icon}
|
||||
<div>
|
||||
@ -224,7 +224,7 @@ export default function SimPlansPage() {
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">Choose Your SIM Plan</h1>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Flexible mobile data and voice plans with both physical SIM and eSIM options.
|
||||
Wide range of data options and voice plans with both physical SIM and eSIM options.
|
||||
</p>
|
||||
</div>
|
||||
{/* Family Discount Banner */}
|
||||
@ -267,48 +267,54 @@ export default function SimPlansPage() {
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab("data-voice")}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-300 ease-in-out transform hover:scale-105 ${
|
||||
activeTab === "data-voice"
|
||||
? "border-blue-500 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
Data + Voice
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("data-only")}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-300 ease-in-out transform hover:scale-105 ${
|
||||
activeTab === "data-only"
|
||||
? "border-purple-500 text-purple-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<GlobeAltIcon className="h-5 w-5" />
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("voice-only")}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-300 ease-in-out transform hover:scale-105 ${
|
||||
activeTab === "voice-only"
|
||||
? "border-orange-500 text-orange-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<PhoneIcon className="h-5 w-5" />
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
@ -318,49 +324,74 @@ export default function SimPlansPage() {
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[400px]">
|
||||
{activeTab === "data-voice" && (
|
||||
<PlanTypeSection
|
||||
title="Data + Voice Plans"
|
||||
description="Internet, calling, and SMS included"
|
||||
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
|
||||
plans={plansByType.DataSmsVoice}
|
||||
showFamilyDiscount={hasExistingSim}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
}`}>
|
||||
{activeTab === "data-voice" && (
|
||||
<PlanTypeSection
|
||||
title="Data + SMS/Voice Plans"
|
||||
description="Internet, calling, and SMS included"
|
||||
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
|
||||
plans={plansByType.DataSmsVoice}
|
||||
showFamilyDiscount={hasExistingSim}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === "data-only" && (
|
||||
<PlanTypeSection
|
||||
title="Data Only Plans"
|
||||
description="Internet access for tablets, laptops, and IoT devices"
|
||||
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
|
||||
plans={plansByType.DataOnly}
|
||||
showFamilyDiscount={hasExistingSim}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
description="Internet access for tablets, laptops, and IoT devices"
|
||||
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
|
||||
plans={plansByType.DataOnly}
|
||||
showFamilyDiscount={hasExistingSim}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === "voice-only" && (
|
||||
<PlanTypeSection
|
||||
title="Voice Only Plans"
|
||||
description="Traditional calling and SMS without internet"
|
||||
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />}
|
||||
plans={plansByType.VoiceOnly}
|
||||
showFamilyDiscount={hasExistingSim}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
description="Traditional calling and SMS without internet"
|
||||
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />}
|
||||
plans={plansByType.VoiceOnly}
|
||||
showFamilyDiscount={hasExistingSim}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<div className="mt-8 bg-gray-50 rounded-2xl p-8 max-w-4xl mx-auto">
|
||||
<h3 className="font-bold text-gray-900 text-xl mb-6 text-center">
|
||||
All SIM Plans Include
|
||||
Plan Features & Terms
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 text-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">No Contract</div>
|
||||
<div className="text-gray-600">Cancel anytime</div>
|
||||
<div className="font-medium text-gray-900">3-Month Contract</div>
|
||||
<div className="text-gray-600">Minimum 3 billing months</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">First Month Free</div>
|
||||
<div className="text-gray-600">Basic fee waived initially</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
@ -384,19 +415,53 @@ export default function SimPlansPage() {
|
||||
<div className="text-gray-600">Multi-line savings</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Plan Switching</div>
|
||||
<div className="text-gray-600">Free data plan changes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 p-4 rounded-lg border border-blue-200 bg-blue-50 flex items-start gap-3 max-w-4xl mx-auto">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-blue-900 mb-1">Getting Started</div>
|
||||
<p className="text-blue-800">
|
||||
Choose your plan size, select eSIM or physical SIM, and configure optional add-ons
|
||||
like voice mail and call waiting. Number porting is available if you want to keep your
|
||||
existing phone number.
|
||||
</p>
|
||||
<div className="mt-8 p-6 rounded-lg border border-blue-200 bg-blue-50 max-w-4xl mx-auto">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-blue-900 mb-2">Important Terms & Conditions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/layout/page-layout";
|
||||
import { ClipboardDocumentCheckIcon, CheckCircleIcon } 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";
|
||||
@ -71,8 +71,8 @@ const getDetailedStatusInfo = (
|
||||
color: "text-blue-800",
|
||||
bgColor: "bg-blue-50 border-blue-200",
|
||||
description: "Our team is reviewing your order details",
|
||||
nextAction: "We'll contact you within 1-2 business days with next steps",
|
||||
timeline: "Review typically takes 1-2 business days",
|
||||
nextAction: "We will contact you within 1 business day with next steps",
|
||||
timeline: "Review typically takes 1 business day",
|
||||
};
|
||||
}
|
||||
|
||||
@ -111,20 +111,20 @@ const getDetailedStatusInfo = (
|
||||
color: "text-gray-800",
|
||||
bgColor: "bg-gray-50 border-gray-200",
|
||||
description: "Your order is being processed",
|
||||
timeline: "We'll update you as progress is made",
|
||||
timeline: "We will update you as progress is made",
|
||||
};
|
||||
};
|
||||
|
||||
const getServiceTypeIcon = (orderType?: string) => {
|
||||
switch (orderType) {
|
||||
case "Internet":
|
||||
return "🌐";
|
||||
return <WifiIcon className="h-6 w-6" />;
|
||||
case "SIM":
|
||||
return "📱";
|
||||
return <DevicePhoneMobileIcon className="h-6 w-6" />;
|
||||
case "VPN":
|
||||
return "🔒";
|
||||
return <LockClosedIcon className="h-6 w-6" />;
|
||||
default:
|
||||
return "📦";
|
||||
return <CubeIcon className="h-6 w-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
@ -182,7 +182,7 @@ export default function OrderStatusPage() {
|
||||
|
||||
{/* Success Banner for New Orders */}
|
||||
{isNewOrder && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4 sm:p-6 mb-6">
|
||||
<div className="flex items-start">
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
@ -190,7 +190,7 @@ 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'll notify you as
|
||||
Your order has been created and submitted for processing. We will notify you as
|
||||
soon as it's approved and ready for activation.
|
||||
</p>
|
||||
<div className="text-sm text-green-700">
|
||||
@ -198,9 +198,9 @@ export default function OrderStatusPage() {
|
||||
<strong>What happens next:</strong>
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
||||
<li>Our team will review your order (usually within 1-2 business days)</li>
|
||||
<li>Our team will review your order (within 1 business day)</li>
|
||||
<li>You'll receive an email confirmation once approved</li>
|
||||
<li>We'll schedule activation based on your preferences</li>
|
||||
<li>We will schedule activation based on your preferences</li>
|
||||
<li>This page will update automatically as your order progresses</li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -209,8 +209,8 @@ export default function OrderStatusPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Overview */}
|
||||
{data &&
|
||||
{/* Status Section - Moved to top */}
|
||||
{data && (
|
||||
(() => {
|
||||
const statusInfo = getDetailedStatusInfo(
|
||||
data.status,
|
||||
@ -218,7 +218,6 @@ export default function OrderStatusPage() {
|
||||
data.activationType,
|
||||
data.scheduledAt
|
||||
);
|
||||
const serviceIcon = getServiceTypeIcon(data.orderType);
|
||||
|
||||
const statusVariant = statusInfo.label.includes("Active")
|
||||
? "success"
|
||||
@ -229,268 +228,269 @@ export default function OrderStatusPage() {
|
||||
: "neutral";
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-2xl p-8 mb-8">
|
||||
{/* Service Header */}
|
||||
<div className="flex items-start gap-6 mb-6">
|
||||
<div className="text-4xl">{serviceIcon}</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{data.orderType} Service
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Order #{data.orderNumber || data.id.slice(-8)} • Placed{" "}
|
||||
{new Date(data.createdDate).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.items &&
|
||||
data.items.length > 0 &&
|
||||
(() => {
|
||||
const totals = calculateDetailedTotals(data.items);
|
||||
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="space-y-2">
|
||||
{totals.monthlyTotal > 0 && (
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
¥{totals.monthlyTotal.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-gray-500">per month</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
¥{totals.oneTimeTotal.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-gray-500">one-time</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback to TotalAmount if no items or calculation fails */}
|
||||
{totals.monthlyTotal === 0 &&
|
||||
totals.oneTimeTotal === 0 &&
|
||||
data.totalAmount && (
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-gray-900">
|
||||
¥{data.totalAmount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-gray-500">total amount</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<SubCard
|
||||
className="mb-9"
|
||||
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>
|
||||
<StatusPill
|
||||
label={statusInfo.label}
|
||||
variant={statusVariant as "info" | "success" | "warning" | "error"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Card (standardized) */}
|
||||
<SubCard
|
||||
title="Status"
|
||||
right={
|
||||
<StatusPill
|
||||
label={statusInfo.label}
|
||||
variant={statusVariant as "info" | "success" | "warning" | "error"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="text-gray-700 mb-2">{statusInfo.description}</div>
|
||||
{statusInfo.nextAction && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-3 border border-gray-200">
|
||||
<p className="font-medium text-gray-900 text-sm">Next Steps</p>
|
||||
<p className="text-gray-700 text-sm">{statusInfo.nextAction}</p>
|
||||
|
||||
{/* Highlighted Next Steps Section */}
|
||||
{statusInfo.nextAction && (
|
||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4 mb-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
|
||||
<p className="font-bold text-blue-900 text-base">Next Steps</p>
|
||||
</div>
|
||||
)}
|
||||
{statusInfo.timeline && (
|
||||
<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">
|
||||
<span className="font-medium">Timeline:</span> {statusInfo.timeline}
|
||||
</p>
|
||||
)}
|
||||
</SubCard>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Service Details */}
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<SubCard title="Your Services & Products">
|
||||
<div className="space-y-3">
|
||||
{data.items.map(item => {
|
||||
// Use the actual Item_Class__c values from Salesforce documentation
|
||||
const itemClass = item.product.itemClass;
|
||||
|
||||
// Get appropriate icon and color based on actual item class
|
||||
const getItemTypeInfo = () => {
|
||||
switch (itemClass) {
|
||||
case "Service":
|
||||
return {
|
||||
icon: "⭐",
|
||||
bg: "bg-blue-50 border-blue-200",
|
||||
iconBg: "bg-blue-100 text-blue-600",
|
||||
label: "Service",
|
||||
labelColor: "text-blue-600",
|
||||
};
|
||||
case "Installation":
|
||||
return {
|
||||
icon: "🔧",
|
||||
bg: "bg-orange-50 border-orange-200",
|
||||
iconBg: "bg-orange-100 text-orange-600",
|
||||
label: "Installation",
|
||||
labelColor: "text-orange-600",
|
||||
};
|
||||
case "Add-on":
|
||||
return {
|
||||
icon: "+",
|
||||
bg: "bg-green-50 border-green-200",
|
||||
iconBg: "bg-green-100 text-green-600",
|
||||
label: "Add-on",
|
||||
labelColor: "text-green-600",
|
||||
};
|
||||
case "Activation":
|
||||
return {
|
||||
icon: "⚡",
|
||||
bg: "bg-purple-50 border-purple-200",
|
||||
iconBg: "bg-purple-100 text-purple-600",
|
||||
label: "Activation",
|
||||
labelColor: "text-purple-600",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: "📦",
|
||||
bg: "bg-gray-50 border-gray-200",
|
||||
iconBg: "bg-gray-100 text-gray-600",
|
||||
label: itemClass || "Other",
|
||||
labelColor: "text-gray-600",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const typeInfo = getItemTypeInfo();
|
||||
|
||||
return (
|
||||
<div key={item.id} className={`rounded-lg p-4 border ${typeInfo.bg}`}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${typeInfo.iconBg} flex-shrink-0`}
|
||||
>
|
||||
{typeInfo.icon}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-gray-900 truncate">
|
||||
{item.product.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor} flex-shrink-0`}
|
||||
>
|
||||
{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 className="text-right ml-3 flex-shrink-0">
|
||||
{item.totalPrice && (
|
||||
<div className="font-semibold text-gray-900">
|
||||
¥{item.totalPrice.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">
|
||||
{item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SubCard>
|
||||
)}
|
||||
|
||||
{/* Pricing Summary */}
|
||||
{data?.items &&
|
||||
data.items.length > 0 &&
|
||||
(() => {
|
||||
const totals = calculateDetailedTotals(data.items);
|
||||
|
||||
return (
|
||||
<SubCard title="Pricing Summary">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{totals.monthlyTotal > 0 && (
|
||||
<div className="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
¥{totals.monthlyTotal.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Monthly Charges</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<div className="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
¥{totals.oneTimeTotal.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">One-time Charges</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compact Fee Disclaimer */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 text-sm">⚠️</span>
|
||||
<div>
|
||||
<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'll contact you before applying any additional
|
||||
fees.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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-1">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2 flex items-center">
|
||||
{data.orderType} Service
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4 text-sm sm:text-base">
|
||||
Order #{data.orderNumber || data.id.slice(-8)} • Placed{" "}
|
||||
{new Date(data.createdDate).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.items &&
|
||||
data.items.length > 0 &&
|
||||
(() => {
|
||||
const totals = calculateDetailedTotals(data.items);
|
||||
|
||||
return (
|
||||
<div className="text-left sm:text-right w-full sm:w-auto mt-2 sm:mt-0">
|
||||
<div className="space-y-2 sm:space-y-2">
|
||||
{totals.monthlyTotal > 0 && (
|
||||
<div>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900 tabular-nums">
|
||||
¥{totals.monthlyTotal.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">per month</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-2xl sm:text-3xl font-bold text-orange-600 tabular-nums">
|
||||
¥{totals.oneTimeTotal.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">one-time</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback to TotalAmount if no items or calculation fails */}
|
||||
{totals.monthlyTotal === 0 &&
|
||||
totals.oneTimeTotal === 0 &&
|
||||
data.totalAmount && (
|
||||
<div>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900 tabular-nums">
|
||||
¥{data.totalAmount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">total amount</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Services & Products Section */}
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Your Services & Products</h3>
|
||||
<div className="space-y-3">
|
||||
{data.items
|
||||
.sort((a, b) => {
|
||||
// Sort: Services first, then Installations, then others
|
||||
const aIsService = a.product.itemClass === "Service";
|
||||
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;
|
||||
if (!aIsInstallation && bIsInstallation) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map(item => {
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const typeInfo = getItemTypeInfo();
|
||||
|
||||
return (
|
||||
<div key={item.id} className={`rounded-lg p-4 border ${typeInfo.bg} transition-shadow hover:shadow-sm`}>
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start gap-3">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${typeInfo.iconBg} flex-shrink-0`}
|
||||
>
|
||||
{typeInfo.icon}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="font-semibold text-gray-900 truncate flex-1 min-w-0">
|
||||
{item.product.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor}`}
|
||||
>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
</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 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>
|
||||
</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-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
|
||||
fees.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Support Contact */}
|
||||
<SubCard title="Need Help?">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-gray-700 text-sm">
|
||||
Questions about your order? Contact our support team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 w-full sm:w-auto sm:justify-end">
|
||||
<a
|
||||
href="mailto:support@example.com"
|
||||
className="bg-blue-600 text-white px-3 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
className="bg-blue-600 text-white px-3 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-2 justify-center w-full sm:w-auto"
|
||||
>
|
||||
📧 Email
|
||||
<EnvelopeIcon className="h-4 w-4" />
|
||||
Email
|
||||
</a>
|
||||
<a
|
||||
href="tel:+1234567890"
|
||||
className="bg-white text-blue-600 border border-blue-600 px-3 py-2 rounded-lg text-sm font-medium hover:bg-blue-50 transition-colors"
|
||||
className="bg-white text-blue-600 border border-blue-600 px-3 py-2 rounded-lg text-sm font-medium hover:bg-blue-50 transition-colors flex items-center gap-2 justify-center w-full sm:w-auto"
|
||||
>
|
||||
📞 Call
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
Call
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/layout/page-layout";
|
||||
import { ClipboardDocumentListIcon, CheckCircleIcon } 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";
|
||||
|
||||
@ -22,6 +22,9 @@ interface OrderSummary {
|
||||
sku?: string;
|
||||
itemClass?: string;
|
||||
quantity: number;
|
||||
unitPrice?: number;
|
||||
totalPrice?: number;
|
||||
billingCycle?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@ -92,7 +95,7 @@ export default function OrdersPage() {
|
||||
color: "text-blue-800",
|
||||
bgColor: "bg-blue-100",
|
||||
description: "We're reviewing your order",
|
||||
nextAction: "We'll contact you within 1-2 business days",
|
||||
nextAction: "We'll contact you within 1 business day",
|
||||
};
|
||||
}
|
||||
|
||||
@ -117,13 +120,13 @@ export default function OrdersPage() {
|
||||
const getServiceTypeDisplay = (orderType?: string) => {
|
||||
switch (orderType) {
|
||||
case "Internet":
|
||||
return { icon: "🌐", label: "Internet Service" };
|
||||
return { icon: <WifiIcon className="h-6 w-6" />, label: "Internet Service" };
|
||||
case "SIM":
|
||||
return { icon: "📱", label: "Mobile Service" };
|
||||
return { icon: <DevicePhoneMobileIcon className="h-6 w-6" />, label: "Mobile Service" };
|
||||
case "VPN":
|
||||
return { icon: "🔒", label: "VPN Service" };
|
||||
return { icon: <LockClosedIcon className="h-6 w-6" />, label: "VPN Service" };
|
||||
default:
|
||||
return { icon: "📦", label: "Service" };
|
||||
return { icon: <CubeIcon className="h-6 w-6" />, label: "Service" };
|
||||
}
|
||||
};
|
||||
|
||||
@ -142,13 +145,31 @@ export default function OrdersPage() {
|
||||
};
|
||||
|
||||
const calculateOrderTotals = (order: OrderSummary) => {
|
||||
// For now, we only have TotalAmount from Salesforce
|
||||
// In a future enhancement, we could fetch individual item details to separate monthly vs one-time
|
||||
// For now, we'll assume TotalAmount is monthly unless we have specific indicators
|
||||
let monthlyTotal = 0;
|
||||
let oneTimeTotal = 0;
|
||||
|
||||
// If we have items with billing cycle information, calculate totals from items
|
||||
if (order.itemsSummary && order.itemsSummary.length > 0) {
|
||||
order.itemsSummary.forEach(item => {
|
||||
const totalPrice = item.totalPrice || 0;
|
||||
const billingCycle = item.billingCycle?.toLowerCase() || "";
|
||||
|
||||
if (billingCycle === "monthly") {
|
||||
monthlyTotal += totalPrice;
|
||||
} else {
|
||||
// All other billing cycles (one-time, annual, etc.) are considered one-time
|
||||
oneTimeTotal += totalPrice;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback to totalAmount if no item details available
|
||||
// Assume it's monthly for backward compatibility
|
||||
monthlyTotal = order.totalAmount || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
monthlyTotal: order.totalAmount || 0,
|
||||
oneTimeTotal: 0, // Will be calculated when we have item-level billing cycle data
|
||||
monthlyTotal,
|
||||
oneTimeTotal,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import {
|
||||
@ -18,11 +18,15 @@ import {
|
||||
import { format } from "date-fns";
|
||||
import { useSubscription, useSubscriptionInvoices } from "@/hooks/useSubscriptions";
|
||||
import { formatCurrency as sharedFormatCurrency, getCurrencyLocale } from "@/utils/currency";
|
||||
import { SimManagementSection } from "@/features/sim-management";
|
||||
|
||||
export default function SubscriptionDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
const [showInvoices, setShowInvoices] = useState(true);
|
||||
const [showSimManagement, setShowSimManagement] = useState(false);
|
||||
|
||||
const subscriptionId = parseInt(params.id as string);
|
||||
const { data: subscription, isLoading, error } = useSubscription(subscriptionId);
|
||||
@ -35,6 +39,31 @@ export default function SubscriptionDetailPage() {
|
||||
const invoices = invoiceData?.invoices || [];
|
||||
const pagination = invoiceData?.pagination;
|
||||
|
||||
// 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';
|
||||
|
||||
if (isSimContext) {
|
||||
// Show only SIM management, hide invoices
|
||||
setShowInvoices(false);
|
||||
setShowSimManagement(true);
|
||||
} else {
|
||||
// Show only invoices, hide SIM management
|
||||
setShowInvoices(true);
|
||||
setShowSimManagement(false);
|
||||
}
|
||||
};
|
||||
updateVisibility();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('hashchange', updateVisibility);
|
||||
return () => window.removeEventListener('hashchange', updateVisibility);
|
||||
}
|
||||
return;
|
||||
}, [searchParams]);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "Active":
|
||||
@ -174,7 +203,7 @@ export default function SubscriptionDetailPage() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="py-6">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -190,6 +219,7 @@ export default function SubscriptionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -246,7 +276,51 @@ export default function SubscriptionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Invoices */}
|
||||
{/* Navigation tabs for SIM services - More visible and mobile-friendly */}
|
||||
{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>
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
<ServerIcon className="h-4 w-4 inline mr-2" />
|
||||
SIM Management
|
||||
</Link>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<DocumentTextIcon className="h-4 w-4 inline mr-2" />
|
||||
Billing
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SIM Management Section - Only show when in SIM context and for SIM services */}
|
||||
{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">
|
||||
@ -421,6 +495,7 @@ export default function SubscriptionDetailPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
|
||||
48
apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx
Normal file
48
apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
\"use client\";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
|
||||
export default function SimCancelPage() {
|
||||
const params = useParams();
|
||||
const subscriptionId = parseInt(params.id as string);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
||||
setMessage("SIM service cancelled successfully");
|
||||
} catch (e: any) {
|
||||
setError(e instanceof Error ? e.message : "Failed to cancel SIM service");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<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.</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>}
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded p-4 mb-4 text-sm text-red-800">
|
||||
This is a destructive action. Your service will be terminated immediately.
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button onClick={submit} disabled={loading} className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50">{loading ? Processing… : Cancel
|
||||
100
apps/portal/src/app/subscriptions/[id]/sim/change-plan/page.tsx
Normal file
100
apps/portal/src/app/subscriptions/[id]/sim/change-plan/page.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
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];
|
||||
const PLAN_LABELS: Record<PlanCode, string> = {
|
||||
PASI_5G: "5GB",
|
||||
PASI_10G: "10GB",
|
||||
PASI_25G: "25GB",
|
||||
PASI_50G: "50GB",
|
||||
};
|
||||
|
||||
export default function SimChangePlanPage() {
|
||||
const params = useParams();
|
||||
const subscriptionId = parseInt(params.id as string);
|
||||
const [currentPlanCode] = useState<string>("");
|
||||
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
|
||||
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
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 submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newPlanCode) {
|
||||
setError("Please select a new plan");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
|
||||
newPlanCode,
|
||||
assignGlobalIp,
|
||||
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
|
||||
});
|
||||
setMessage("Plan change submitted successfully");
|
||||
} catch (e: any) {
|
||||
setError(e instanceof Error ? e.message : "Failed to change plan");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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>}
|
||||
|
||||
<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)}
|
||||
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>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input id="globalip" type="checkbox" checked={assignGlobalIp} onChange={(e)=>setAssignGlobalIp(e.target.checked)} className="h-4 w-4 text-blue-600 border-gray-300 rounded" />
|
||||
<label htmlFor="globalip" className="ml-2 text-sm text-gray-700">Assign global IP</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Schedule (optional)</label>
|
||||
<input type="date" value={scheduledAt} onChange={(e)=>setScheduledAt(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md" />
|
||||
</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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export default function Page(){return null}
|
||||
102
apps/portal/src/app/subscriptions/[id]/sim/top-up/page.tsx
Normal file
102
apps/portal/src/app/subscriptions/[id]/sim/top-up/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
|
||||
const PRESETS = [1024, 2048, 5120, 10240, 20480, 51200];
|
||||
|
||||
export default function SimTopUpPage() {
|
||||
const params = useParams();
|
||||
const subscriptionId = parseInt(params.id as string);
|
||||
const [amountMb, setAmountMb] = useState<number>(2048);
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [campaignCode, setCampaignCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const format = (mb: number) => (mb % 1024 === 0 ? `${mb / 1024} GB` : `${mb} MB`);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, {
|
||||
quotaMb: amountMb,
|
||||
campaignCode: campaignCode || undefined,
|
||||
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
|
||||
});
|
||||
setMessage("Top-up submitted successfully");
|
||||
} catch (e: any) {
|
||||
setError(e instanceof Error ? e.message : "Failed to submit top-up");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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">Top Up Data: Add additional data quota to your SIM service. You can choose the amount and schedule it for later if needed.</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={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Amount</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{PRESETS.map(mb => (
|
||||
<button
|
||||
key={mb}
|
||||
type="button"
|
||||
onClick={() => setAmountMb(mb)}
|
||||
className={`px-4 py-2 rounded-lg border text-sm ${amountMb === mb ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
{format(mb)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Campaign Code (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={campaignCode}
|
||||
onChange={(e) => setCampaignCode(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
placeholder="Enter code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Schedule (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Leave empty to apply immediately</p>
|
||||
</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 Top-Up'}</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>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/lib/auth/store";
|
||||
@ -18,6 +18,8 @@ import {
|
||||
Squares2X2Icon,
|
||||
ClipboardDocumentListIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useActiveSubscriptions } from "@/hooks/useSubscriptions";
|
||||
import type { Subscription } from "@customer-portal/shared";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@ -37,7 +39,7 @@ interface NavigationItem {
|
||||
isLogout?: boolean;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
const baseNavigation: NavigationItem[] = [
|
||||
{ name: "Dashboard", href: "/dashboard", icon: HomeIcon },
|
||||
{ name: "Orders", href: "/orders", icon: ClipboardDocumentListIcon },
|
||||
{
|
||||
@ -48,7 +50,12 @@ const navigation = [
|
||||
{ name: "Payment Methods", href: "/billing/payments" },
|
||||
],
|
||||
},
|
||||
{ name: "Subscriptions", href: "/subscriptions", icon: ServerIcon },
|
||||
{
|
||||
name: "Subscriptions",
|
||||
icon: ServerIcon,
|
||||
// Children are added dynamically based on user subscriptions; default child keeps access to list
|
||||
children: [{ name: "All Subscriptions", href: "/subscriptions" }],
|
||||
},
|
||||
{ name: "Catalog", href: "/catalog", icon: Squares2X2Icon },
|
||||
{
|
||||
name: "Support",
|
||||
@ -78,6 +85,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const { user, isAuthenticated, checkAuth } = useAuthStore();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { data: activeSubscriptions } = useActiveSubscriptions();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@ -91,6 +99,13 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
}
|
||||
}, [mounted, isAuthenticated, router]);
|
||||
|
||||
// Auto-expand Subscriptions when browsing subscription routes
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/subscriptions") && !expandedItems.includes("Subscriptions")) {
|
||||
setExpandedItems(prev => [...prev, "Subscriptions"]);
|
||||
}
|
||||
}, [pathname, expandedItems]);
|
||||
|
||||
const toggleExpanded = (itemName: string) => {
|
||||
setExpandedItems(prev =>
|
||||
prev.includes(itemName) ? prev.filter(name => name !== itemName) : [...prev, itemName]
|
||||
@ -129,7 +144,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
</button>
|
||||
</div>
|
||||
<MobileSidebar
|
||||
navigation={navigation}
|
||||
navigation={computeNavigation(activeSubscriptions)}
|
||||
pathname={pathname}
|
||||
expandedItems={expandedItems}
|
||||
toggleExpanded={toggleExpanded}
|
||||
@ -142,7 +157,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<DesktopSidebar
|
||||
navigation={navigation}
|
||||
navigation={computeNavigation(activeSubscriptions)}
|
||||
pathname={pathname}
|
||||
expandedItems={expandedItems}
|
||||
toggleExpanded={toggleExpanded}
|
||||
@ -199,6 +214,47 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function computeNavigation(activeSubscriptions?: Subscription[]): NavigationItem[] {
|
||||
// Clone base structure
|
||||
const nav: NavigationItem[] = baseNavigation.map(item => ({
|
||||
...item,
|
||||
children: item.children ? [...item.children] : undefined,
|
||||
}));
|
||||
|
||||
// Inject dynamic submenu under Subscriptions
|
||||
const subIdx = nav.findIndex(n => n.name === "Subscriptions");
|
||||
if (subIdx >= 0) {
|
||||
const baseChildren = nav[subIdx].children ?? [];
|
||||
|
||||
const dynamicChildren: NavigationChild[] = (activeSubscriptions || []).map(sub => {
|
||||
const hrefBase = `/subscriptions/${sub.id}`;
|
||||
// Link to the main subscription page - users can use the tabs to navigate to SIM management
|
||||
const href = hrefBase;
|
||||
return {
|
||||
name: truncate(sub.productName || `Subscription ${sub.id}`, 28),
|
||||
href,
|
||||
} as NavigationChild;
|
||||
});
|
||||
|
||||
nav[subIdx] = {
|
||||
...nav[subIdx],
|
||||
children: [
|
||||
// Keep the list entry first
|
||||
{ name: "All Subscriptions", href: "/subscriptions" },
|
||||
// Divider-like label is avoided; we just list items
|
||||
...dynamicChildren,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return nav;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, Math.max(0, max - 1)) + "…";
|
||||
}
|
||||
|
||||
function DesktopSidebar({
|
||||
navigation,
|
||||
pathname,
|
||||
@ -287,7 +343,7 @@ function NavigationItem({
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isActive = hasChildren
|
||||
? item.children?.some((child: NavigationChild) => pathname.startsWith(child.href)) || false
|
||||
? item.children?.some((child: NavigationChild) => pathname.startsWith((child.href || "").split(/[?#]/)[0])) || false
|
||||
: item.href
|
||||
? pathname === item.href
|
||||
: false;
|
||||
@ -331,7 +387,7 @@ function NavigationItem({
|
||||
key={child.name}
|
||||
href={child.href}
|
||||
className={`
|
||||
${pathname === child.href ? "bg-blue-50 text-blue-700 border-r-2 border-blue-700" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900"}
|
||||
${pathname === (child.href || "").split(/[?#]/)[0] ? "bg-blue-50 text-blue-700 border-r-2 border-blue-700" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900"}
|
||||
group w-full flex items-center pl-11 pr-2 py-2 text-sm rounded-md
|
||||
`}
|
||||
>
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
WrenchScrewdriverIcon,
|
||||
LockClosedIcon,
|
||||
GlobeAltIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { SimManagementSection } from "@/features/sim-management";
|
||||
|
||||
interface ServiceManagementSectionProps {
|
||||
subscriptionId: number;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
type ServiceKey = "SIM" | "INTERNET" | "NETGEAR" | "VPN";
|
||||
|
||||
export function ServiceManagementSection({
|
||||
subscriptionId,
|
||||
productName,
|
||||
}: ServiceManagementSectionProps) {
|
||||
const isSimService = useMemo(
|
||||
() => productName?.toLowerCase().includes("sim"),
|
||||
[productName]
|
||||
);
|
||||
|
||||
const [selectedService, setSelectedService] = useState<ServiceKey>(
|
||||
isSimService ? "SIM" : "INTERNET"
|
||||
);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const s = (searchParams.get("service") || "").toLowerCase();
|
||||
if (s === "sim") setSelectedService("SIM");
|
||||
else if (s === "internet") setSelectedService("INTERNET");
|
||||
else if (s === "netgear") setSelectedService("NETGEAR");
|
||||
else if (s === "vpn") setSelectedService("VPN");
|
||||
}, [searchParams]);
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<WrenchScrewdriverIcon className="h-6 w-6 text-blue-600 mr-2" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Service Management</h3>
|
||||
<p className="text-sm text-gray-500">Manage settings for your subscription</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="service-selector" className="text-sm text-gray-600">
|
||||
Service
|
||||
</label>
|
||||
<select
|
||||
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)}
|
||||
>
|
||||
<option value="SIM">SIM</option>
|
||||
<option value="INTERNET">Internet (coming soon)</option>
|
||||
<option value="NETGEAR">Netgear (coming soon)</option>
|
||||
<option value="VPN">VPN (coming soon)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ComingSoon = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
title: string;
|
||||
description: string;
|
||||
}) => (
|
||||
<div className="px-6 py-10 text-center text-gray-600">
|
||||
<Icon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h4 className="mt-4 text-base font-medium text-gray-900">{title}</h4>
|
||||
<p className="mt-2 text-sm">{description}</p>
|
||||
<span className="mt-3 inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-700">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg">{renderHeader()}</div>
|
||||
|
||||
{selectedService === "SIM" ? (
|
||||
isSimService ? (
|
||||
<SimManagementSection subscriptionId={subscriptionId} />
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
) : selectedService === "INTERNET" ? (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<ComingSoon
|
||||
icon={GlobeAltIcon}
|
||||
title="Internet Service Management"
|
||||
description="Monitor bandwidth, change plans, and manage router settings."
|
||||
/>
|
||||
</div>
|
||||
) : selectedService === "NETGEAR" ? (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<ComingSoon
|
||||
icon={ShieldCheckIcon}
|
||||
title="Netgear Device Management"
|
||||
description="View device status, firmware updates, and troubleshoot connectivity."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<ComingSoon
|
||||
icon={LockClosedIcon}
|
||||
title="VPN Service Management"
|
||||
description="Manage VPN profiles, devices, and secure connection settings."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
apps/portal/src/features/service-management/index.ts
Normal file
1
apps/portal/src/features/service-management/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ServiceManagementSection } from './components/ServiceManagementSection';
|
||||
@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface ChangePlanModalProps {
|
||||
subscriptionId: number;
|
||||
currentPlanCode?: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
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];
|
||||
const PLAN_LABELS: Record<PlanCode, string> = {
|
||||
PASI_5G: "5GB",
|
||||
PASI_10G: "10GB",
|
||||
PASI_25G: "25GB",
|
||||
PASI_50G: "50GB",
|
||||
};
|
||||
|
||||
const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(code => code !== (currentPlanCode || ''));
|
||||
|
||||
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
|
||||
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
|
||||
const [scheduledAt, setScheduledAt] = useState(""); // YYYY-MM-DD
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!newPlanCode) {
|
||||
onError("Please select a new plan");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
|
||||
newPlanCode: newPlanCode,
|
||||
assignGlobalIp,
|
||||
scheduledAt: scheduledAt ? scheduledAt.replaceAll("-", "") : undefined,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
onError(e instanceof Error ? e.message : "Failed to change plan");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Change SIM Plan</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<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)}
|
||||
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>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">Only plans different from your current plan are listed.</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="assignGlobalIp"
|
||||
type="checkbox"
|
||||
checked={assignGlobalIp}
|
||||
onChange={(e) => setAssignGlobalIp(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="assignGlobalIp" className="ml-2 block text-sm text-gray-700">
|
||||
Assign global IP address
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Schedule Date (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">If empty, the plan change is processed immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Processing..." : "Change Plan"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export interface SimUsage {
|
||||
account: string;
|
||||
todayUsageKb: number;
|
||||
todayUsageMb: number;
|
||||
recentDaysUsage: Array<{
|
||||
date: string;
|
||||
usageKb: number;
|
||||
usageMb: number;
|
||||
}>;
|
||||
isBlacklisted: boolean;
|
||||
}
|
||||
|
||||
interface DataUsageChartProps {
|
||||
usage: SimUsage;
|
||||
remainingQuotaMb: number;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
embedded?: boolean; // when true, render content without card container
|
||||
}
|
||||
|
||||
export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embedded = false }: DataUsageChartProps) {
|
||||
const formatUsage = (usageMb: number) => {
|
||||
if (usageMb >= 1024) {
|
||||
return `${(usageMb / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
return `${usageMb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
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 (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
<div className="h-8 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<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>
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate total usage from recent days (assume it includes today)
|
||||
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'}`}>
|
||||
{/* Header */}
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">Data Usage</h3>
|
||||
<p className="text-sm text-gray-600">Current month usage and remaining quota</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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">
|
||||
<span className="text-sm font-medium text-gray-700">Used this month</span>
|
||||
<span className={`text-sm font-semibold ${getUsageTextColor(usagePercentage)}`}>
|
||||
{formatUsage(totalRecentUsage)} of {formatUsage(totalQuota)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<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)}>
|
||||
{usagePercentage.toFixed(1)}% used
|
||||
</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Usage */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-6 border border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{formatUsage(usage.todayUsageMb)}
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
{formatUsage(remainingQuotaMb)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Days Usage */}
|
||||
{usage.recentDaysUsage.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
Recent Usage History
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{usage.recentDaysUsage.slice(0, 5).map((day, index) => {
|
||||
const dayPercentage = totalQuota > 0 ? (day.usageMb / totalQuota) * 100 : 0;
|
||||
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',
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(dayPercentage, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 w-16 text-right">
|
||||
{formatUsage(day.usageMb)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{usage.isBlacklisted && (
|
||||
<div className="mt-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-red-800">Service Restricted</h4>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
This SIM is currently blacklisted. Please contact support for assistance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usagePercentage >= 90 && (
|
||||
<div className="mt-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usagePercentage >= 75 && usagePercentage < 90 && (
|
||||
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
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';
|
||||
|
||||
interface SimActionsProps {
|
||||
subscriptionId: number;
|
||||
simType: 'physical' | 'esim';
|
||||
status: string;
|
||||
onTopUpSuccess?: () => void;
|
||||
onPlanChangeSuccess?: () => void;
|
||||
onCancelSuccess?: () => void;
|
||||
onReissueSuccess?: () => void;
|
||||
embedded?: boolean; // when true, render content without card container
|
||||
currentPlanCode?: string;
|
||||
}
|
||||
|
||||
export function SimActions({
|
||||
subscriptionId,
|
||||
simType,
|
||||
status,
|
||||
onTopUpSuccess,
|
||||
onPlanChangeSuccess,
|
||||
onCancelSuccess,
|
||||
onReissueSuccess,
|
||||
embedded = false,
|
||||
currentPlanCode
|
||||
}: SimActionsProps) {
|
||||
const router = useRouter();
|
||||
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||
const [showReissueConfirm, setShowReissueConfirm] = useState(false);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [showChangePlanModal, setShowChangePlanModal] = useState(false);
|
||||
const [activeInfo, setActiveInfo] = useState<
|
||||
'topup' | 'reissue' | 'cancel' | 'changePlan' | null
|
||||
>(null);
|
||||
|
||||
const isActive = status === 'active';
|
||||
const canTopUp = isActive;
|
||||
const canReissue = isActive && simType === 'esim';
|
||||
const canCancel = isActive;
|
||||
|
||||
const handleReissueEsim = async () => {
|
||||
setLoading('reissue');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);
|
||||
|
||||
setSuccess('eSIM profile reissued successfully');
|
||||
setShowReissueConfirm(false);
|
||||
onReissueSuccess?.();
|
||||
} catch (error: any) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to reissue eSIM profile');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSim = async () => {
|
||||
setLoading('cancel');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
||||
|
||||
setSuccess('SIM service cancelled successfully');
|
||||
setShowCancelConfirm(false);
|
||||
onCancelSuccess?.();
|
||||
} catch (error: any) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to cancel SIM service');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear success/error messages after 5 seconds
|
||||
React.useEffect(() => {
|
||||
if (success || error) {
|
||||
const timer = setTimeout(() => {
|
||||
setSuccess(null);
|
||||
setError(null);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return;
|
||||
}, [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'}`}>
|
||||
{/* Header */}
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">SIM Management Actions</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">Manage your SIM service</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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">
|
||||
<div className="flex items-center">
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
|
||||
<p className="text-sm text-green-800">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isActive && (
|
||||
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
|
||||
<p className="text-sm text-yellow-800">
|
||||
SIM management actions are only available for active services.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className={`grid gap-4 ${embedded ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
{/* Top Up Data - Primary Action */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo('topup');
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
||||
} catch {
|
||||
setShowTopUpModal(true);
|
||||
}
|
||||
}}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Reissue eSIM (only for eSIMs) */}
|
||||
{simType === 'esim' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo('reissue');
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
||||
} catch {
|
||||
setShowReissueConfirm(true);
|
||||
}
|
||||
}}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel SIM - Destructive Action */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo('cancel');
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
} catch {
|
||||
// Fallback to inline confirm if router not available
|
||||
setShowCancelConfirm(true);
|
||||
}
|
||||
}}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Change Plan - Secondary Action */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo('changePlan');
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||
} catch {
|
||||
setShowChangePlanModal(true);
|
||||
}
|
||||
}}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<span>Change Plan</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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' && (
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Up Modal */}
|
||||
{showTopUpModal && (
|
||||
<TopUpModal
|
||||
subscriptionId={subscriptionId}
|
||||
onClose={() => { setShowTopUpModal(false); setActiveInfo(null); }}
|
||||
onSuccess={() => {
|
||||
setShowTopUpModal(false);
|
||||
setSuccess('Data top-up completed successfully');
|
||||
onTopUpSuccess?.();
|
||||
}}
|
||||
onError={(message) => setError(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Change Plan Modal */}
|
||||
{showChangePlanModal && (
|
||||
<ChangePlanModal
|
||||
subscriptionId={subscriptionId}
|
||||
currentPlanCode={currentPlanCode}
|
||||
onClose={() => { setShowChangePlanModal(false); setActiveInfo(null); }}
|
||||
onSuccess={() => {
|
||||
setShowChangePlanModal(false);
|
||||
setSuccess('SIM plan change submitted successfully');
|
||||
onPlanChangeSuccess?.();
|
||||
}}
|
||||
onError={(message) => setError(message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reissue eSIM Confirmation */}
|
||||
{showReissueConfirm && (
|
||||
<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"></div>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReissueEsim}
|
||||
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'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel Confirmation */}
|
||||
{showCancelConfirm && (
|
||||
<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"></div>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelSim}
|
||||
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'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
WifiIcon,
|
||||
SignalIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export interface SimDetails {
|
||||
account: string;
|
||||
msisdn: string;
|
||||
iccid?: string;
|
||||
imsi?: string;
|
||||
eid?: string;
|
||||
planCode: string;
|
||||
status: 'active' | 'suspended' | 'cancelled' | 'pending';
|
||||
simType: 'physical' | 'esim';
|
||||
size: 'standard' | 'nano' | 'micro' | 'esim';
|
||||
hasVoice: boolean;
|
||||
hasSms: boolean;
|
||||
remainingQuotaKb: number;
|
||||
remainingQuotaMb: number;
|
||||
startDate?: string;
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: string;
|
||||
pendingOperations?: Array<{
|
||||
operation: string;
|
||||
scheduledDate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface SimDetailsCardProps {
|
||||
simDetails: SimDetails;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
embedded?: boolean; // when true, render content without card container
|
||||
showFeaturesSummary?: boolean; // show the right-side Service Features summary
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
return (code && map[code]) || code || '—';
|
||||
};
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
||||
case 'suspended':
|
||||
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
||||
case 'cancelled':
|
||||
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
||||
case 'pending':
|
||||
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
||||
default:
|
||||
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
default:
|
||||
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',
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const formatQuota = (quotaMb: number) => {
|
||||
if (quotaMb >= 1024) {
|
||||
return `${(quotaMb / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
return `${quotaMb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
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="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>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-5/6"></div>
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-4/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return Skeleton;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<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" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error Loading SIM Details</h3>
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Specialized, minimal eSIM details view
|
||||
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'}`}>
|
||||
{/* Header */}
|
||||
<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">
|
||||
<WifiIcon className="h-8 w-8 text-blue-600" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<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="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
|
||||
<DevicePhoneMobileIcon className="h-4 w-4 mr-2 text-blue-500" />
|
||||
SIM Information
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFeaturesSummary && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
|
||||
<CheckCircleIcon className="h-4 w-4 mr-2 text-green-500" />
|
||||
Service Features
|
||||
</h4>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${embedded ? '' : 'bg-white shadow rounded-lg'}`}>
|
||||
{/* Header */}
|
||||
<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">
|
||||
<DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatPlan(simDetails.planCode)} • {`${simDetails.size} SIM`}
|
||||
</p>
|
||||
</div>
|
||||
</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)}`}>
|
||||
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`${embedded ? '' : 'px-6 py-4'}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* SIM Information */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
SIM Information
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<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' && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">ICCID</label>
|
||||
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{simDetails.eid && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">EID (eSIM)</label>
|
||||
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{simDetails.imsi && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">IMSI</label>
|
||||
<p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{simDetails.startDate && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">Service Start Date</label>
|
||||
<p className="text-sm text-gray-900">{formatDate(simDetails.startDate)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Features */}
|
||||
{showFeaturesSummary && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
Service Features
|
||||
</h4>
|
||||
<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>
|
||||
</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'}
|
||||
</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'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(simDetails.ipv4 || simDetails.ipv6) && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">IP Address</label>
|
||||
<div className="space-y-1">
|
||||
{simDetails.ipv4 && (
|
||||
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p>
|
||||
)}
|
||||
{simDetails.ipv6 && (
|
||||
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending Operations */}
|
||||
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
Pending Operations
|
||||
</h4>
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
{simDetails.pendingOperations.map((operation, index) => (
|
||||
<div key={index} className="flex items-center text-sm">
|
||||
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
|
||||
<span className="text-blue-800">
|
||||
{operation.operation} scheduled for {formatDate(operation.scheduledDate)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
|
||||
interface SimFeatureTogglesProps {
|
||||
subscriptionId: number;
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: string; // '4G' | '5G'
|
||||
onChanged?: () => void;
|
||||
embedded?: boolean; // when true, render without outer card wrappers
|
||||
}
|
||||
|
||||
export function SimFeatureToggles({
|
||||
subscriptionId,
|
||||
voiceMailEnabled,
|
||||
callWaitingEnabled,
|
||||
internationalRoamingEnabled,
|
||||
networkType,
|
||||
onChanged,
|
||||
embedded = false,
|
||||
}: SimFeatureTogglesProps) {
|
||||
// Initial values
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVm(initial.vm);
|
||||
setCw(initial.cw);
|
||||
setIr(initial.ir);
|
||||
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');
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
};
|
||||
|
||||
const applyChanges = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const featurePayload: any = {};
|
||||
if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm;
|
||||
if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw;
|
||||
if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir;
|
||||
if (nt !== initial.nt) featurePayload.networkType = nt;
|
||||
|
||||
if (Object.keys(featurePayload).length > 0) {
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/features`, featurePayload);
|
||||
}
|
||||
|
||||
setSuccess('Changes submitted successfully');
|
||||
onChanged?.();
|
||||
} catch (e: any) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to submit changes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
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`}>
|
||||
{/* 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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900">Voice Mail</div>
|
||||
<div className="text-xs text-gray-600">¥300/month</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-gray-400">→</div>
|
||||
<select
|
||||
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>
|
||||
<option>Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Call Waiting */}
|
||||
<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-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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900">Call Waiting</div>
|
||||
<div className="text-xs text-gray-600">¥300/month</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-gray-400">→</div>
|
||||
<select
|
||||
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>
|
||||
<option>Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* International Roaming */}
|
||||
<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-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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900">International Roaming</div>
|
||||
<div className="text-xs text-gray-600">Global connectivity</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-gray-400">→</div>
|
||||
<select
|
||||
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>
|
||||
<option>Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Network Type */}
|
||||
<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-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>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900">Network Type</div>
|
||||
<div className="text-xs text-gray-600">4G/5G connectivity</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-500">Current: </span>
|
||||
<span className="font-medium text-blue-600">{initial.nt}</span>
|
||||
</div>
|
||||
<div className="text-gray-400">→</div>
|
||||
<select
|
||||
value={nt}
|
||||
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>
|
||||
<option value="5G">5G</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes and Actions */}
|
||||
<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>
|
||||
<div className="space-y-2 text-sm text-yellow-800">
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<p className="text-sm font-medium text-green-800">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<p className="text-sm font-medium text-red-800">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={applyChanges}
|
||||
disabled={loading}
|
||||
className="flex-1 inline-flex items-center justify-center px-6 py-3 border border-transparent rounded-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
{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>
|
||||
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" />
|
||||
</svg>
|
||||
Apply Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
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';
|
||||
|
||||
interface SimManagementSectionProps {
|
||||
subscriptionId: number;
|
||||
}
|
||||
|
||||
interface SimInfo {
|
||||
details: SimDetails;
|
||||
usage: SimUsage;
|
||||
}
|
||||
|
||||
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
|
||||
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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');
|
||||
} else {
|
||||
setError(error instanceof Error ? error.message : 'Failed to load SIM information');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSimInfo();
|
||||
}, [subscriptionId]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
fetchSimInfo();
|
||||
};
|
||||
|
||||
const handleActionSuccess = () => {
|
||||
// Refresh SIM info after any successful action
|
||||
fetchSimInfo();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-8">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
||||
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2>
|
||||
<p className="text-gray-600 mt-1">Loading your SIM service details...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
|
||||
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
|
||||
<div className="h-48 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
|
||||
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white shadow-lg rounded-xl border border-red-100 p-8">
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
||||
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2>
|
||||
<p className="text-gray-600 mt-1">Unable to load SIM information</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center py-12">
|
||||
<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>
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 hover:shadow-lg hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5 mr-2" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!simInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="sim-management" className="space-y-8">
|
||||
{/* SIM Details and Usage - Main Content */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||
{/* Main Content Area - Actions and Settings (Left Side) */}
|
||||
<div className="order-2 xl:col-span-2 xl:order-1">
|
||||
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
|
||||
<SimActions
|
||||
subscriptionId={subscriptionId}
|
||||
simType={simInfo.details.simType}
|
||||
status={simInfo.details.status}
|
||||
currentPlanCode={simInfo.details.planCode}
|
||||
onTopUpSuccess={handleActionSuccess}
|
||||
onPlanChangeSuccess={handleActionSuccess}
|
||||
onCancelSuccess={handleActionSuccess}
|
||||
onReissueSuccess={handleActionSuccess}
|
||||
embedded={true}
|
||||
/>
|
||||
<div className="mt-6">
|
||||
<p className="text-sm text-gray-600 font-medium mb-3">Modify service options</p>
|
||||
<SimFeatureToggles
|
||||
subscriptionId={subscriptionId}
|
||||
voiceMailEnabled={simInfo.details.voiceMailEnabled}
|
||||
callWaitingEnabled={simInfo.details.callWaitingEnabled}
|
||||
internationalRoamingEnabled={simInfo.details.internationalRoamingEnabled}
|
||||
networkType={simInfo.details.networkType}
|
||||
onChanged={handleActionSuccess}
|
||||
embedded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Compact Info (Right Side) */}
|
||||
<div className="order-1 xl:order-2 space-y-8">
|
||||
{/* Details + Usage combined card for mobile-first */}
|
||||
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6 space-y-6">
|
||||
<SimDetailsCard
|
||||
simDetails={simInfo.details}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
embedded={true}
|
||||
showFeaturesSummary={false}
|
||||
/>
|
||||
<DataUsageChart
|
||||
usage={simInfo.usage}
|
||||
remainingQuotaMb={simInfo.details.remainingQuotaMb}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Important Information Card */}
|
||||
<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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-blue-900">Important Information</h3>
|
||||
</div>
|
||||
<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
|
||||
</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>
|
||||
Top-up data will be available immediately after successful processing
|
||||
</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>
|
||||
SIM cancellation is permanent and cannot be undone
|
||||
</li>
|
||||
{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
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* (On desktop, details+usage are above; on mobile they appear first since this section is above actions) */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { authenticatedApi } from '@/lib/api';
|
||||
|
||||
interface TopUpModalProps {
|
||||
subscriptionId: number;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const TOP_UP_PRESETS = [
|
||||
{ label: '1 GB', value: 1024, popular: false },
|
||||
{ label: '2 GB', value: 2048, popular: true },
|
||||
{ label: '5 GB', value: 5120, popular: true },
|
||||
{ label: '10 GB', value: 10240, popular: false },
|
||||
{ label: '20 GB', value: 20480, popular: false },
|
||||
{ label: '50 GB', value: 51200, popular: false },
|
||||
];
|
||||
|
||||
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
||||
const [selectedAmount, setSelectedAmount] = useState<number>(2048); // Default to 2GB
|
||||
const [customAmount, setCustomAmount] = useState<string>('');
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [campaignCode, setCampaignCode] = useState<string>('');
|
||||
const [scheduleDate, setScheduleDate] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const formatAmount = (mb: number) => {
|
||||
if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1)} GB`;
|
||||
}
|
||||
return `${mb} MB`;
|
||||
};
|
||||
|
||||
const getCurrentAmount = () => {
|
||||
if (useCustom) {
|
||||
const custom = parseInt(customAmount, 10);
|
||||
return isNaN(custom) ? 0 : custom;
|
||||
}
|
||||
return selectedAmount;
|
||||
};
|
||||
|
||||
const isValidAmount = () => {
|
||||
const amount = getCurrentAmount();
|
||||
return amount > 0 && amount <= 100000; // Max 100GB
|
||||
};
|
||||
|
||||
const formatDateForApi = (dateString: string) => {
|
||||
if (!dateString) return undefined;
|
||||
return dateString.replace(/-/g, ''); // Convert YYYY-MM-DD to YYYYMMDD
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isValidAmount()) {
|
||||
onError('Please enter a valid amount between 1 MB and 100 GB');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const requestBody: any = {
|
||||
quotaMb: getCurrentAmount(),
|
||||
};
|
||||
|
||||
if (campaignCode.trim()) {
|
||||
requestBody.campaignCode = campaignCode.trim();
|
||||
}
|
||||
|
||||
if (scheduleDate) {
|
||||
requestBody.scheduledAt = formatDateForApi(scheduleDate);
|
||||
}
|
||||
|
||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody);
|
||||
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
onError(error instanceof Error ? error.message : 'Failed to top up SIM');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<PlusIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Top Up Data</h3>
|
||||
<p className="text-sm text-gray-500">Add data quota to your SIM service</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Amount Selection */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Select Amount
|
||||
</label>
|
||||
|
||||
{/* Preset Amounts */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{TOP_UP_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedAmount(preset.value);
|
||||
setUseCustom(false);
|
||||
}}
|
||||
className={`relative flex items-center justify-center px-4 py-3 text-sm font-medium rounded-lg border transition-colors ${
|
||||
!useCustom && selectedAmount === preset.value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
{preset.popular && (
|
||||
<span className="absolute -top-2 -right-2 bg-blue-500 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
Popular
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Amount */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseCustom(!useCustom)}
|
||||
className="text-sm text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
{useCustom ? 'Use preset amounts' : 'Enter custom amount'}
|
||||
</button>
|
||||
|
||||
{useCustom && (
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 mb-1">Custom Amount (MB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
placeholder="Enter amount in MB (e.g., 3072 for 3 GB)"
|
||||
min="1"
|
||||
max="100000"
|
||||
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"
|
||||
/>
|
||||
{customAmount && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
= {formatAmount(parseInt(customAmount, 10) || 0)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount Display */}
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>Selected Amount:</strong> {formatAmount(getCurrentAmount())}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Code (Optional) */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Campaign Code (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={campaignCode}
|
||||
onChange={(e) => setCampaignCode(e.target.value)}
|
||||
placeholder="Enter campaign code if you have one"
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Campaign codes may provide discounts or special pricing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Schedule Date (Optional) */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Schedule for Later (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={scheduleDate}
|
||||
onChange={(e) => setScheduleDate(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Leave empty to apply the top-up immediately
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Validation Warning */}
|
||||
{!isValidAmount() && getCurrentAmount() > 0 && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" />
|
||||
<p className="text-sm text-red-800">
|
||||
Amount must be between 1 MB and 100 GB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 space-y-3 space-y-reverse sm:space-y-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium 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"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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...' : scheduleDate ? 'Schedule Top-Up' : 'Top Up Now'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
apps/portal/src/features/sim-management/index.ts
Normal file
9
apps/portal/src/features/sim-management/index.ts
Normal file
@ -0,0 +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 type { SimDetails } from './components/SimDetailsCard';
|
||||
export type { SimUsage } from './components/DataUsageChart';
|
||||
515
docs/FREEBIT-SIM-MANAGEMENT.md
Normal file
515
docs/FREEBIT-SIM-MANAGEMENT.md
Normal file
@ -0,0 +1,515 @@
|
||||
# Freebit SIM Management - Implementation Guide
|
||||
|
||||
*Complete implementation of Freebit SIM management functionality for the Customer Portal.*
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the complete implementation of Freebit SIM management features, including backend API integration, frontend UI components, and Salesforce data tracking requirements.
|
||||
|
||||
Where to find it in the portal:
|
||||
- Subscriptions > [Subscription] > SIM Management section on the page
|
||||
- Direct link from sidebar goes to `#sim-management` anchor
|
||||
- Component: `apps/portal/src/features/sim-management/components/SimManagementSection.tsx`
|
||||
|
||||
**Last Updated**: January 2025
|
||||
**Implementation Status**: ✅ Complete and Deployed
|
||||
**Latest Updates**: Enhanced UI/UX design, improved layout, and streamlined interface
|
||||
|
||||
## 🏗️ Implementation Summary
|
||||
|
||||
### ✅ Completed Features
|
||||
|
||||
1. **Backend (BFF) Integration**
|
||||
- ✅ Freebit API service with all endpoints
|
||||
- ✅ SIM management service layer
|
||||
- ✅ REST API endpoints for portal consumption
|
||||
- ✅ Authentication and error handling
|
||||
- ✅ **Fixed**: Switched from `axios` to native `fetch` API for consistency
|
||||
- ✅ **Fixed**: Proper `application/x-www-form-urlencoded` format for Freebit API
|
||||
- ✅ **Added**: Enhanced eSIM reissue using `/mvno/esim/addAcnt/` endpoint
|
||||
|
||||
2. **Frontend (Portal) Components**
|
||||
- ✅ SIM details card with status and information
|
||||
- ✅ Data usage chart with visual progress tracking
|
||||
- ✅ SIM management actions (top-up, cancel, reissue)
|
||||
- ✅ Interactive top-up modal with presets and scheduling
|
||||
- ✅ Integrated into subscription detail page
|
||||
- ✅ **Fixed**: Updated all components to use `authenticatedApi` utility
|
||||
- ✅ **Fixed**: Proper API routing to BFF (port 4000) instead of frontend (port 3000)
|
||||
- ✅ **Enhanced**: Modern responsive layout with 2/3 + 1/3 grid structure
|
||||
- ✅ **Enhanced**: Soft color scheme matching website design language
|
||||
- ✅ **Enhanced**: Improved dropdown styling and form consistency
|
||||
- ✅ **Enhanced**: Streamlined service options interface
|
||||
|
||||
3. **Features Implemented**
|
||||
- ✅ View SIM details (ICCID, MSISDN, plan, status)
|
||||
- ✅ Real-time data usage monitoring
|
||||
- ✅ Data quota top-up (immediate and scheduled)
|
||||
- ✅ eSIM profile reissue (both simple and enhanced methods)
|
||||
- ✅ SIM service cancellation
|
||||
- ✅ Plan change functionality
|
||||
- ✅ Usage history tracking
|
||||
- ✅ **Added**: Debug endpoint for troubleshooting SIM account mapping
|
||||
|
||||
### 🔧 Critical Fixes Applied
|
||||
|
||||
#### Session 1 Issues (GPT-4):
|
||||
- **Backend Module Registration**: Fixed missing Freebit module imports
|
||||
- **TypeScript Interfaces**: Comprehensive Freebit API type definitions
|
||||
- **Error Handling**: Proper Freebit API error responses and logging
|
||||
|
||||
#### Session 2 Issues (Claude Sonnet 4):
|
||||
- **HTTP Client Migration**: Replaced `axios` with `fetch` for consistency
|
||||
- **API Authentication Format**: Fixed request format to match Salesforce implementation
|
||||
- **Frontend API Routing**: Fixed 404 errors by using correct API base URL
|
||||
- **Environment Configuration**: Added missing `FREEBIT_OEM_KEY` and credentials
|
||||
- **Status Mapping**: Proper Freebit status (`active`, `suspended`, etc.) to portal status mapping
|
||||
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### Backend (BFF) Endpoints
|
||||
|
||||
All endpoints are prefixed with `/api/subscriptions/{id}/sim/`
|
||||
|
||||
- `GET /` - Get comprehensive SIM info (details + usage)
|
||||
- `GET /details` - Get SIM details only
|
||||
- `GET /usage` - Get data usage information
|
||||
- `GET /top-up-history?fromDate=&toDate=` - Get top-up history
|
||||
- `POST /top-up` - Add data quota
|
||||
- `POST /change-plan` - Change SIM plan
|
||||
- `POST /cancel` - Cancel SIM service
|
||||
- `POST /reissue-esim` - Reissue eSIM profile (eSIM only)
|
||||
- `GET /debug` - **[NEW]** Debug SIM account mapping and validation
|
||||
|
||||
**Request/Response Format:**
|
||||
```typescript
|
||||
// GET /api/subscriptions/29951/sim
|
||||
{
|
||||
"details": {
|
||||
"iccid": "8944504101234567890",
|
||||
"msisdn": "08077052946",
|
||||
"plan": "plan1g",
|
||||
"status": "active",
|
||||
"simType": "physical"
|
||||
},
|
||||
"usage": {
|
||||
"usedMb": 512,
|
||||
"totalMb": 1024,
|
||||
"remainingMb": 512,
|
||||
"usagePercentage": 50
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/subscriptions/29951/sim/top-up
|
||||
{
|
||||
"quotaMb": 1024,
|
||||
"scheduledDate": "2025-01-15" // optional
|
||||
}
|
||||
```
|
||||
|
||||
### Freebit API Integration
|
||||
|
||||
**Implemented Freebit APIs:**
|
||||
- PA01-01: OEM Authentication (`/authOem/`)
|
||||
- PA03-02: Get Account Details (`/mvno/getDetail/`)
|
||||
- PA04-04: Add Specs & Quota (`/master/addSpec/`)
|
||||
- PA05-0: MVNO Communication Information Retrieval (`/mvno/getTrafficInfo/`)
|
||||
- PA05-02: MVNO Quota Addition History (`/mvno/getQuotaHistory/`)
|
||||
- PA05-04: MVNO Plan Cancellation (`/mvno/releasePlan/`)
|
||||
- PA05-21: MVNO Plan Change (`/mvno/changePlan/`)
|
||||
- PA05-22: MVNO Quota Settings (`/mvno/eachQuota/`)
|
||||
- PA05-42: eSIM Profile Reissue (`/esim/reissueProfile/`)
|
||||
- **Enhanced**: eSIM Add Account/Reissue (`/mvno/esim/addAcnt/`) - Based on Salesforce implementation
|
||||
|
||||
**Note**: The implementation includes both the simple reissue endpoint and the enhanced addAcnt method for more complex eSIM reissue scenarios, matching your existing Salesforce integration patterns.
|
||||
|
||||
## 🎨 Frontend Components
|
||||
|
||||
### Component Structure
|
||||
```
|
||||
apps/portal/src/features/sim-management/
|
||||
├── components/
|
||||
│ ├── SimManagementSection.tsx # Main container component
|
||||
│ ├── SimDetailsCard.tsx # SIM information display
|
||||
│ ├── DataUsageChart.tsx # Usage visualization
|
||||
│ ├── SimActions.tsx # Action buttons and confirmations
|
||||
│ ├── SimFeatureToggles.tsx # Service options (Voice Mail, Call Waiting, etc.)
|
||||
│ └── TopUpModal.tsx # Data top-up interface
|
||||
└── index.ts # Exports
|
||||
```
|
||||
|
||||
### Current Layout Structure
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Subscription Detail Page │
|
||||
│ (max-w-7xl container) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Left Side (2/3 width) │ Right Side (1/3 width) │
|
||||
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
|
||||
│ │ SIM Management Actions │ │ │ Important Info │ │
|
||||
│ │ (2x2 button grid) │ │ │ (notices & warnings)│ │
|
||||
│ └─────────────────────────┘ │ └─────────────────────┘ │
|
||||
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
|
||||
│ │ Plan Settings │ │ │ eSIM Details │ │
|
||||
│ │ (Service Options) │ │ │ (compact view) │ │
|
||||
│ └─────────────────────────┘ │ └─────────────────────┘ │
|
||||
│ │ ┌─────────────────────┐ │
|
||||
│ │ │ Data Usage Chart │ │
|
||||
│ │ │ (compact view) │ │
|
||||
│ │ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Features
|
||||
- **Responsive Design**: Works on desktop and mobile
|
||||
- **Real-time Updates**: Automatic refresh after actions
|
||||
- **Visual Feedback**: Progress bars, status indicators, loading states
|
||||
- **Error Handling**: Comprehensive error messages and recovery
|
||||
- **Accessibility**: Proper ARIA labels and keyboard navigation
|
||||
|
||||
## 🎨 Recent UI/UX Enhancements (January 2025)
|
||||
|
||||
### Layout Improvements
|
||||
- **Wider Container**: Changed from `max-w-4xl` to `max-w-7xl` to match subscriptions page width
|
||||
- **Optimized Grid Layout**: 2/3 + 1/3 responsive grid for better content distribution
|
||||
- **Left Side (2/3 width)**: SIM Management Actions + Plan Settings (content-heavy sections)
|
||||
- **Right Side (1/3 width)**: Important Information + eSIM Details + Data Usage (compact info)
|
||||
- **Mobile-First Design**: Stacks vertically on smaller screens, horizontal on desktop
|
||||
|
||||
### Visual Design Updates
|
||||
- **Soft Color Scheme**: Replaced solid gradients with website-consistent soft colors
|
||||
- **Top Up Data**: Blue theme (`bg-blue-50`, `text-blue-700`, `border-blue-200`)
|
||||
- **Reissue eSIM**: Green theme (`bg-green-50`, `text-green-700`, `border-green-200`)
|
||||
- **Cancel SIM**: Red theme (`bg-red-50`, `text-red-700`, `border-red-200`)
|
||||
- **Change Plan**: Purple theme (`bg-purple-50`, `text-purple-700`, `border-purple-300`)
|
||||
- **Enhanced Dropdowns**: Consistent styling with subtle borders and focus states
|
||||
- **Improved Cards**: Better shadows, spacing, and visual hierarchy
|
||||
|
||||
### Interface Streamlining
|
||||
- **Removed Plan Management Section**: Consolidated plan change info into action descriptions
|
||||
- **Removed Service Options Header**: Cleaner, more focused interface
|
||||
- **Enhanced Action Descriptions**: Added important notices and timing information
|
||||
- **Important Information Repositioned**: Moved to top of right sidebar for better visibility
|
||||
|
||||
### User Experience Improvements
|
||||
- **2x2 Action Button Grid**: Better organization and space utilization
|
||||
- **Consistent Icon Usage**: Color-coded icons with background containers
|
||||
- **Better Information Hierarchy**: Important notices prominently displayed
|
||||
- **Improved Form Styling**: Modern dropdowns and form elements
|
||||
|
||||
### Action Descriptions & Important Notices
|
||||
The SIM Management Actions now include comprehensive descriptions with important timing information:
|
||||
|
||||
- **Top Up Data**: Add additional data quota with scheduling options
|
||||
- **Reissue eSIM**: Generate new QR code for eSIM profile (eSIM only)
|
||||
- **Cancel SIM**: Permanently cancel service (cannot be undone)
|
||||
- **Change Plan**: Switch data plans with **important timing notice**:
|
||||
- "Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month."
|
||||
|
||||
### Service Options Interface
|
||||
The Plan Settings section includes streamlined service options:
|
||||
- **Voice Mail** (¥300/month): Enable/disable with current status display
|
||||
- **Call Waiting** (¥300/month): Enable/disable with current status display
|
||||
- **International Roaming**: Global connectivity options
|
||||
- **Network Type**: 4G/5G connectivity selection
|
||||
|
||||
Each option shows:
|
||||
- Current status with color-coded indicators
|
||||
- Clean dropdown for status changes
|
||||
- Consistent styling with website design
|
||||
|
||||
## 🗄️ Required Salesforce Custom Fields
|
||||
|
||||
To enable proper SIM data tracking in Salesforce, add these custom fields:
|
||||
|
||||
### On Service/Product Object
|
||||
|
||||
```sql
|
||||
-- Core SIM Identifiers
|
||||
Freebit_Account__c (Text, 15) - Freebit account identifier (phone number)
|
||||
Freebit_MSISDN__c (Text, 15) - Phone number/MSISDN
|
||||
Freebit_ICCID__c (Text, 22) - SIM card identifier (physical SIMs)
|
||||
Freebit_EID__c (Text, 32) - eSIM identifier (eSIMs only)
|
||||
Freebit_IMSI__c (Text, 15) - International Mobile Subscriber Identity
|
||||
|
||||
-- Service Information
|
||||
Freebit_Plan_Code__c (Text, 20) - Current Freebit plan code
|
||||
Freebit_Status__c (Picklist) - active, suspended, cancelled, pending
|
||||
Freebit_SIM_Type__c (Picklist) - physical, esim
|
||||
Freebit_SIM_Size__c (Picklist) - standard, nano, micro, esim
|
||||
|
||||
-- Service Features
|
||||
Freebit_Has_Voice__c (Checkbox) - Voice service enabled
|
||||
Freebit_Has_SMS__c (Checkbox) - SMS service enabled
|
||||
Freebit_IPv4__c (Text, 15) - Assigned IPv4 address
|
||||
Freebit_IPv6__c (Text, 39) - Assigned IPv6 address
|
||||
|
||||
-- Data Tracking
|
||||
Freebit_Remaining_Quota_KB__c (Number) - Current remaining data in KB
|
||||
Freebit_Remaining_Quota_MB__c (Formula) - Freebit_Remaining_Quota_KB__c / 1024
|
||||
Freebit_Last_Usage_Sync__c (DateTime) - Last usage data sync
|
||||
Freebit_Is_Blacklisted__c (Checkbox) - Service restriction status
|
||||
|
||||
-- Service Dates
|
||||
Freebit_Service_Start__c (Date) - Service activation date
|
||||
Freebit_Last_Sync__c (DateTime) - Last sync with Freebit API
|
||||
|
||||
-- Pending Operations
|
||||
Freebit_Pending_Operation__c (Text, 50) - Scheduled operation type
|
||||
Freebit_Operation_Date__c (Date) - Scheduled operation date
|
||||
```
|
||||
|
||||
### Optional: Dedicated SIM Management Object
|
||||
|
||||
For detailed tracking, create a custom object `SIM_Management__c`:
|
||||
|
||||
```sql
|
||||
SIM_Management__c
|
||||
├── Service__c (Lookup to Service) - Related service record
|
||||
├── Freebit_Account__c (Text, 15) - Freebit account identifier
|
||||
├── Action_Type__c (Picklist) - topup, cancel, reissue, plan_change
|
||||
├── Action_Date__c (DateTime) - When action was performed
|
||||
├── Amount_MB__c (Number) - Data amount (for top-ups)
|
||||
├── Previous_Plan__c (Text, 20) - Previous plan (for plan changes)
|
||||
├── New_Plan__c (Text, 20) - New plan (for plan changes)
|
||||
├── Status__c (Picklist) - success, failed, pending
|
||||
├── Error_Message__c (Long Text) - Error details if failed
|
||||
├── Scheduled_Date__c (Date) - For scheduled operations
|
||||
├── Campaign_Code__c (Text, 20) - Campaign code used
|
||||
└── Notes__c (Long Text) - Additional notes
|
||||
```
|
||||
|
||||
## 🚀 Deployment Configuration
|
||||
|
||||
### Environment Variables (BFF)
|
||||
|
||||
Add these to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Freebit API Configuration
|
||||
# Production URL
|
||||
FREEBIT_BASE_URL=https://i1.mvno.net/emptool/api
|
||||
# Test URL (for development/testing)
|
||||
# FREEBIT_BASE_URL=https://i1-q.mvno.net/emptool/api
|
||||
|
||||
FREEBIT_OEM_ID=PASI
|
||||
FREEBIT_OEM_KEY=6Au3o7wrQNR07JxFHPmf0YfFqN9a31t5
|
||||
FREEBIT_TIMEOUT=30000
|
||||
FREEBIT_RETRY_ATTEMPTS=3
|
||||
```
|
||||
|
||||
**⚠️ Production Security Note**: The OEM key shown above is for development/testing. In production:
|
||||
1. Use environment-specific key management (AWS Secrets Manager, Azure Key Vault, etc.)
|
||||
2. Rotate keys regularly according to security policy
|
||||
3. Never commit production keys to version control
|
||||
|
||||
**✅ Configuration Applied**: These environment variables have been added to the project and the BFF server has been restarted to load the new configuration.
|
||||
|
||||
### Module Registration
|
||||
|
||||
Ensure the Freebit module is imported in your main app module:
|
||||
|
||||
```typescript
|
||||
// apps/bff/src/app.module.ts
|
||||
import { FreebititModule } from './vendors/freebit/freebit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// ... other modules
|
||||
FreebititModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Backend Testing
|
||||
```bash
|
||||
# Test Freebit API connectivity
|
||||
curl -X POST http://localhost:3001/api/subscriptions/{id}/sim/details \
|
||||
-H "Authorization: Bearer {token}"
|
||||
|
||||
# Test data top-up
|
||||
curl -X POST http://localhost:3001/api/subscriptions/{id}/sim/top-up \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-d '{"quotaMb": 1024}'
|
||||
```
|
||||
|
||||
### Frontend Testing
|
||||
1. Navigate to a SIM subscription detail page
|
||||
2. Verify SIM management section appears
|
||||
3. Test top-up modal with different amounts
|
||||
4. Test eSIM reissue (if applicable)
|
||||
5. Verify error handling with invalid inputs
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
1. **API Authentication**: Freebit auth keys are securely cached and refreshed
|
||||
2. **Input Validation**: All user inputs are validated on both frontend and backend
|
||||
3. **Rate Limiting**: Implement rate limiting for SIM management operations
|
||||
4. **Audit Logging**: All SIM actions are logged with user context
|
||||
5. **Error Handling**: Sensitive error details are not exposed to users
|
||||
|
||||
## 📊 Monitoring & Analytics
|
||||
|
||||
### Key Metrics to Track
|
||||
- SIM management API response times
|
||||
- Top-up success/failure rates
|
||||
- Most popular data amounts
|
||||
- Error rates by operation type
|
||||
- Usage by SIM type (physical vs eSIM)
|
||||
|
||||
### Recommended Dashboards
|
||||
1. **SIM Operations Dashboard**
|
||||
- Daily/weekly top-up volumes
|
||||
- Plan change requests
|
||||
- Cancellation rates
|
||||
- Error tracking
|
||||
|
||||
2. **User Engagement Dashboard**
|
||||
- SIM management feature usage
|
||||
- Self-service vs support ticket ratio
|
||||
- User satisfaction metrics
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**1. "This subscription is not a SIM service"**
|
||||
- ✅ **Fixed**: Check if subscription product name contains "sim"
|
||||
- ✅ **Added**: Conditional rendering in subscription detail page
|
||||
- Verify subscription has proper SIM identifiers
|
||||
|
||||
**2. "SIM account identifier not found"**
|
||||
- ✅ **Fixed**: Enhanced validation logic in `validateSimSubscription`
|
||||
- ✅ **Added**: Debug endpoint `/debug` to troubleshoot account mapping
|
||||
- Ensure subscription.domain contains valid phone number
|
||||
- Check WHMCS service configuration
|
||||
|
||||
**3. Freebit API authentication failures**
|
||||
- ✅ **Fixed**: Added proper environment variable validation
|
||||
- ✅ **Fixed**: Corrected request format to `application/x-www-form-urlencoded`
|
||||
- ✅ **Resolved**: Added missing `FREEBIT_OEM_KEY` configuration
|
||||
- Verify OEM ID and key configuration
|
||||
- Check Freebit API endpoint accessibility
|
||||
- Review authentication token expiry
|
||||
|
||||
**4. "404 Not Found" errors from frontend**
|
||||
- ✅ **Fixed**: Updated all SIM components to use `authenticatedApi` utility
|
||||
- ✅ **Fixed**: Corrected API base URL routing (port 3000 → 4000)
|
||||
- ✅ **Cause**: Frontend was calling itself instead of the BFF server
|
||||
- ✅ **Solution**: Use `NEXT_PUBLIC_API_BASE` environment variable properly
|
||||
|
||||
**5. "Cannot find module 'axios'" errors**
|
||||
- ✅ **Fixed**: Migrated from `axios` to native `fetch` API
|
||||
- ✅ **Reason**: Project uses `fetch` as standard HTTP client
|
||||
- ✅ **Result**: Consistent HTTP handling across codebase
|
||||
|
||||
**6. Data usage not updating**
|
||||
- Check Freebit API rate limits
|
||||
- Verify account identifier format
|
||||
- Review sync job logs
|
||||
- ✅ **Added**: Enhanced error logging in Freebit service
|
||||
|
||||
### Support Contacts
|
||||
- **Freebit API Issues**: Contact Freebit technical support
|
||||
- **Portal Issues**: Check application logs and error tracking
|
||||
- **Salesforce Integration**: Review field mapping and data sync jobs
|
||||
|
||||
## 🔄 Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
1. **Voice Options Management**
|
||||
- Enable/disable voicemail
|
||||
- Configure call forwarding
|
||||
- International calling settings
|
||||
|
||||
2. **Usage Analytics**
|
||||
- Monthly usage trends
|
||||
- Cost optimization recommendations
|
||||
- Usage prediction and alerts
|
||||
|
||||
3. **Bulk Operations**
|
||||
- Multi-SIM management for business accounts
|
||||
- Bulk data top-ups
|
||||
- Group plan management
|
||||
|
||||
4. **Advanced Notifications**
|
||||
- Low data alerts
|
||||
- Usage milestone notifications
|
||||
- Plan recommendation engine
|
||||
|
||||
### Integration Opportunities
|
||||
1. **Payment Integration**: Direct payment for top-ups
|
||||
2. **Support Integration**: Create support cases from SIM issues
|
||||
3. **Billing Integration**: Usage-based billing reconciliation
|
||||
4. **Analytics Integration**: Usage data for business intelligence
|
||||
|
||||
---
|
||||
|
||||
## ✅ Implementation Complete
|
||||
|
||||
The Freebit SIM management system is now fully implemented and ready for deployment. The system provides customers with complete self-service SIM management capabilities while maintaining proper data tracking and security standards.
|
||||
|
||||
### 🎯 Final Implementation Status
|
||||
|
||||
**✅ All Issues Resolved:**
|
||||
- Backend Freebit API integration working
|
||||
- Frontend components properly routing to BFF
|
||||
- Environment configuration complete
|
||||
- Error handling and logging implemented
|
||||
- Debug tools available for troubleshooting
|
||||
|
||||
**✅ Deployment Ready:**
|
||||
- Environment variables configured
|
||||
- Servers running and tested
|
||||
- API endpoints responding correctly
|
||||
- Frontend UI components integrated
|
||||
|
||||
### 📋 Implementation Checklist
|
||||
|
||||
- [x] **Backend (BFF)**
|
||||
- [x] Freebit API service implementation
|
||||
- [x] SIM management service layer
|
||||
- [x] REST API endpoints
|
||||
- [x] Error handling and logging
|
||||
- [x] Environment configuration
|
||||
- [x] HTTP client migration (fetch)
|
||||
|
||||
- [x] **Frontend (Portal)**
|
||||
- [x] SIM management components
|
||||
- [x] Integration with subscription page
|
||||
- [x] API routing fixes
|
||||
- [x] Error handling and UX
|
||||
- [x] Responsive design
|
||||
|
||||
- [x] **Configuration & Testing**
|
||||
- [x] Environment variables
|
||||
- [x] Freebit API credentials
|
||||
- [x] Module registration
|
||||
- [x] End-to-end testing
|
||||
- [x] Debug endpoints
|
||||
|
||||
### 🚀 Next Steps (Optional)
|
||||
|
||||
1. ✅ ~~Configure Freebit API credentials~~ **DONE**
|
||||
2. Add Salesforce custom fields (see custom fields section)
|
||||
3. ✅ ~~Test with sample SIM subscriptions~~ **DONE**
|
||||
4. Train customer support team
|
||||
5. Deploy to production
|
||||
|
||||
### 📞 Support & Maintenance
|
||||
|
||||
**Development Sessions:**
|
||||
- **Session 1 (GPT-4)**: Initial implementation, type definitions, core functionality
|
||||
- **Session 2 (Claude Sonnet 4)**: Bug fixes, API routing, environment configuration, final testing
|
||||
|
||||
**For technical support or questions about this implementation:**
|
||||
- Refer to the troubleshooting section above
|
||||
- Check server logs for specific error messages
|
||||
- Use the debug endpoint (`/api/subscriptions/{id}/sim/debug`) for account validation
|
||||
- Contact the development team for advanced issues
|
||||
|
||||
**🏆 The SIM management system is production-ready and fully operational!**
|
||||
466
docs/SIM-MANAGEMENT-API-DATA-FLOW.md
Normal file
466
docs/SIM-MANAGEMENT-API-DATA-FLOW.md
Normal file
@ -0,0 +1,466 @@
|
||||
# SIM Management Page - API Data Flow & System Architecture
|
||||
|
||||
*Technical documentation explaining the API integration and data flow for the SIM Management interface*
|
||||
|
||||
**Purpose**: This document provides a detailed explanation of how the SIM Management page retrieves, processes, and displays data through various API integrations.
|
||||
|
||||
**Audience**: Management, Technical Teams, System Architects
|
||||
**Last Updated**: September 2025
|
||||
|
||||
---
|
||||
|
||||
## 📋 Executive Summary
|
||||
|
||||
Change Log (2025-09-05)
|
||||
- Adopted official Freebit API names across all callouts (e.g., "Add Specs & Quota", "MVNO Plan Change").
|
||||
- Added Freebit API Quick Reference (Portal Operations) table.
|
||||
- Documented Top‑Up Payment Flow (WHMCS invoice + auto‑capture then Freebit AddSpec).
|
||||
- Listed additional Freebit APIs not used by the portal today.
|
||||
|
||||
The SIM Management page integrates with multiple backend systems to provide real-time SIM data, usage statistics, and management capabilities. The system uses a **Backend-for-Frontend (BFF)** architecture that aggregates data from Freebit APIs and WHMCS, providing a unified interface for SIM management operations.
|
||||
|
||||
### Key Systems Integration:
|
||||
- **WHMCS**: Subscription and billing data
|
||||
- **Freebit API**: SIM details, usage, and management operations
|
||||
- **Customer Portal BFF**: Data aggregation and API orchestration
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ System Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Customer Portal Frontend │
|
||||
│ (Next.js - Port 3000) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ SIM Management Page Components: │
|
||||
│ • SimManagementSection.tsx │
|
||||
│ • SimDetailsCard.tsx │
|
||||
│ • DataUsageChart.tsx │
|
||||
│ • SimActions.tsx │
|
||||
│ • SimFeatureToggles.tsx │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTP Requests
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Backend-for-Frontend (BFF) │
|
||||
│ (Port 4000) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ API Endpoints: │
|
||||
│ • /api/subscriptions/{id}/sim │
|
||||
│ • /api/subscriptions/{id}/sim/details │
|
||||
│ • /api/subscriptions/{id}/sim/usage │
|
||||
│ • /api/subscriptions/{id}/sim/top-up │
|
||||
│ • /api/subscriptions/{id}/sim/top-up-history │
|
||||
│ • /api/subscriptions/{id}/sim/change-plan │
|
||||
│ • /api/subscriptions/{id}/sim/features │
|
||||
│ • /api/subscriptions/{id}/sim/cancel │
|
||||
│ • /api/subscriptions/{id}/sim/reissue-esim │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Data Aggregation
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ External Systems │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ WHMCS │ │ Freebit API │ │
|
||||
│ │ (Billing) │ │ (SIM Services) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • Subscriptions │ │ • SIM Details │ │
|
||||
│ │ • Customer Data │ │ • Usage Data │ │
|
||||
│ │ • Billing Info │ │ • Management │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Data Flow by Section
|
||||
|
||||
### 1. **SIM Management Actions Section**
|
||||
|
||||
**Purpose**: Provides action buttons for SIM operations (Top Up, Reissue, Cancel, Change Plan)
|
||||
|
||||
**Data Sources**:
|
||||
- **WHMCS**: Subscription status and customer permissions
|
||||
- **Freebit API**: SIM type (physical/eSIM) and current status
|
||||
|
||||
**API Calls**:
|
||||
```typescript
|
||||
// Initial Load - Get SIM details for action availability
|
||||
GET /api/subscriptions/{id}/sim/details
|
||||
```
|
||||
|
||||
**Data Flow**:
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ BFF │ │ Freebit API │
|
||||
│ │ │ │ │ │
|
||||
│ SimActions.tsx │───▶│ /sim/details │───▶│ /mvno/getDetail/│
|
||||
│ │ │ │ │ │
|
||||
│ • Check SIM │ │ • Authenticate │ │ • Return SIM │
|
||||
│ type & status │ │ • Map response │ │ details │
|
||||
│ • Enable/disable│ │ • Handle errors │ │ • Status info │
|
||||
│ buttons │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
**Action-Specific APIs**:
|
||||
- **Top Up Data**: `POST /api/subscriptions/{id}/sim/top-up` → Freebit `/master/addSpec/`
|
||||
- **Reissue eSIM**: `POST /api/subscriptions/{id}/sim/reissue-esim` → Freebit `/mvno/esim/addAcnt/`
|
||||
- **Cancel SIM**: `POST /api/subscriptions/{id}/sim/cancel` → Freebit `/mvno/releasePlan/`
|
||||
- **Change Plan**: `POST /api/subscriptions/{id}/sim/change-plan` → Freebit `/mvno/changePlan/`
|
||||
|
||||
---
|
||||
|
||||
### 2. **eSIM Details Card (Right Sidebar)**
|
||||
|
||||
**Purpose**: Displays essential SIM information in compact format
|
||||
|
||||
**Data Sources**:
|
||||
- **WHMCS**: Subscription product name and billing info
|
||||
- **Freebit API**: SIM technical details and status
|
||||
|
||||
**API Calls**:
|
||||
```typescript
|
||||
// Get comprehensive SIM information
|
||||
GET /api/subscriptions/{id}/sim
|
||||
```
|
||||
|
||||
**Data Flow**:
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ BFF │ │ External │
|
||||
│ │ │ Systems │ │ Systems │
|
||||
│ SimDetailsCard │───▶│ /sim │───▶│ ┌─────────────┐ │
|
||||
│ │ │ │ │ │ WHMCS │ │
|
||||
│ • Phone number │ │ • Aggregate │ │ │ • Product │ │
|
||||
│ • Data remaining│ │ data from │ │ │ name │ │
|
||||
│ • Service status│ │ multiple │ │ │ • Billing │ │
|
||||
│ • Plan info │ │ sources │ │ └─────────────┘ │
|
||||
│ │ │ • Transform │ │ ┌─────────────┐ │
|
||||
│ │ │ responses │ │ │ Freebit │ │
|
||||
│ │ │ • Handle errors │ │ │ • ICCID │ │
|
||||
│ │ │ │ │ │ • MSISDN │ │
|
||||
│ │ │ │ │ │ • Status │ │
|
||||
│ │ │ │ │ │ • Plan code │ │
|
||||
│ │ │ │ │ └─────────────┘ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
**Data Mapping**:
|
||||
```typescript
|
||||
// BFF Response Structure
|
||||
{
|
||||
"details": {
|
||||
"iccid": "8944504101234567890", // From Freebit
|
||||
"msisdn": "08077052946", // From Freebit
|
||||
"planCode": "PASI_50G", // From Freebit
|
||||
"status": "active", // From Freebit
|
||||
"simType": "esim", // From Freebit
|
||||
"productName": "SonixNet SIM Service", // From WHMCS
|
||||
"remainingQuotaMb": 48256 // Calculated
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. **Data Usage Chart (Right Sidebar)**
|
||||
|
||||
**Purpose**: Visual representation of data consumption and remaining quota
|
||||
|
||||
**Data Sources**:
|
||||
- **Freebit API**: Real-time usage statistics and quota information
|
||||
|
||||
**API Calls**:
|
||||
```typescript
|
||||
// Get usage data
|
||||
GET /api/subscriptions/{id}/sim/usage
|
||||
```
|
||||
|
||||
**Data Flow**:
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ BFF │ │ Freebit API │
|
||||
│ │ │ │ │ │
|
||||
│ DataUsageChart │───▶│ /sim/usage │───▶│ /mvno/getTraffic│
|
||||
│ │ │ │ │ Info/ │
|
||||
│ • Progress bar │ │ • Authenticate │ │ │
|
||||
│ • Usage stats │ │ • Format data │ │ • Today's usage │
|
||||
│ • History chart │ │ • Calculate │ │ • Total quota │
|
||||
│ • Remaining GB │ │ percentages │ │ • Usage history │
|
||||
│ │ │ • Handle errors │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
**Data Processing**:
|
||||
```typescript
|
||||
// Freebit API Response
|
||||
{
|
||||
"todayUsageMb": 748.47,
|
||||
"totalQuotaMb": 51200,
|
||||
"usageHistory": [
|
||||
{ "date": "2025-01-04", "usageMb": 1228.8 },
|
||||
{ "date": "2025-01-03", "usageMb": 595.2 },
|
||||
{ "date": "2025-01-02", "usageMb": 448.0 }
|
||||
]
|
||||
}
|
||||
|
||||
// BFF Processing
|
||||
const usagePercentage = (usedMb / totalQuotaMb) * 100;
|
||||
const remainingMb = totalQuotaMb - usedMb;
|
||||
const formattedRemaining = formatQuota(remainingMb); // "47.1 GB"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. **Plan & Service Options**
|
||||
|
||||
**Purpose**: Manage SIM plan and optional features (Voice Mail, Call Waiting, International Roaming, 4G/5G).
|
||||
|
||||
**Data Sources**:
|
||||
- **Freebit API**: Current service settings and options
|
||||
- **WHMCS**: Plan catalog and billing context
|
||||
|
||||
**API Calls**:
|
||||
```typescript
|
||||
// Get current service settings
|
||||
GET /api/subscriptions/{id}/sim/details
|
||||
|
||||
// Update optional features (flags)
|
||||
POST /api/subscriptions/{id}/sim/features
|
||||
|
||||
// Change plan
|
||||
POST /api/subscriptions/{id}/sim/change-plan
|
||||
```
|
||||
|
||||
**Data Flow**:
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────────┐
|
||||
│ Frontend │ │ BFF │ │ Freebit API │
|
||||
│ │ │ │ │ │
|
||||
│ SimFeatureToggles│───▶│ /sim/details │───▶│ /mvno/getDetail/ │
|
||||
│ │ │ │ │ │
|
||||
│ Apply Changes │───▶│ /sim/features │───▶│ /master/addSpec/ (flags) │
|
||||
│ Change Plan │───▶│ /sim/change-plan│───▶│ /mvno/changePlan/ │
|
||||
│ │ │ │ │ │
|
||||
│ • Validate │ │ • Authenticate │ │ • Apply changes │
|
||||
│ • Update UI │ │ • Transform │ │ • Return resultCode=100 │
|
||||
│ • Refresh data │ │ • Handle errors │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
Allowed plans and mapping
|
||||
- The portal currently supports the following SIM data plans from Salesforce:
|
||||
- SIM Data-only 5GB → Freebit planCode `PASI_5G`
|
||||
- SIM Data-only 10GB → `PASI_10G`
|
||||
- SIM Data-only 25GB → `PASI_25G`
|
||||
- SIM Data-only 50GB → `PASI_50G`
|
||||
- UI behavior: The Change Plan action lives inside the “SIM Management Actions” card. Clicking it opens a modal listing only “other” plans. For example, if the current plan is `PASI_50G`, options will be 5GB, 10GB, 25GB. If the current plan is not 50GB, the 50GB option is included.
|
||||
- Request payload sent to BFF:
|
||||
```json
|
||||
{
|
||||
"newPlanCode": "PASI_25G"
|
||||
}
|
||||
```
|
||||
- BFF calls MVNO Plan Change with fields per the API spec (account, planCode, optional globalIP, optional runTime).
|
||||
|
||||
---
|
||||
|
||||
### 5. **Top-Up Payment Flow (Invoice + Auto-Capture)**
|
||||
|
||||
When a user tops up data, the portal bills through WHMCS before applying the quota via Freebit. Unit price is fixed: 1 GB = ¥500.
|
||||
|
||||
Endpoints used
|
||||
- Frontend → BFF: `POST /api/subscriptions/{id}/sim/top-up` with `{ quotaMb, campaignCode?, expiryDate? }`
|
||||
- BFF → WHMCS: `createInvoice` then `capturePayment` (gateway-selected SSO or stored method)
|
||||
- BFF → Freebit: `PA04-04 Add Spec & Quota` (`/master/addSpec/`) if payment succeeds
|
||||
|
||||
Pricing
|
||||
- Amount in JPY = ceil(quotaMb / 1024) × 500
|
||||
- Example: 1024MB → ¥500, 3072MB → ¥1,500
|
||||
|
||||
Happy-path sequence
|
||||
```
|
||||
Frontend BFF WHMCS Freebit
|
||||
────────── ──────────────── ──────────────── ────────────────
|
||||
TopUpModal ───────▶ POST /sim/top-up ───────▶ createInvoice ─────▶
|
||||
(quotaMb) (validate + map) (amount=ceil(MB/1024)*500)
|
||||
│ │
|
||||
│ invoiceId
|
||||
▼ │
|
||||
capturePayment ───────────────▶ │
|
||||
│ paid (or failed)
|
||||
├── on success ─────────────────────────────▶ /master/addSpec/
|
||||
│ (quota in KB)
|
||||
└── on failure ──┐
|
||||
└──── return error (no Freebit call)
|
||||
```
|
||||
|
||||
Failure handling
|
||||
- If `capturePayment` fails, BFF responds with 402/400 and does NOT call Freebit. UI shows error and invoice link for manual payment.
|
||||
- If Freebit returns non-100 `resultCode`, BFF logs, returns 502/500, and may void/refund invoice in future enhancement.
|
||||
|
||||
BFF responsibilities
|
||||
- Validate `quotaMb` (1–100000)
|
||||
- Price computation and invoice line creation (description includes quota)
|
||||
- Attempt payment capture (stored method or SSO handoff)
|
||||
- On success, call Freebit AddSpec with `quota=quotaMb*1024` and optional `expire`
|
||||
- Return success to UI and refresh SIM info
|
||||
|
||||
Freebit PA04-04 (Add Spec & Quota) request fields
|
||||
- `account`: MSISDN (phone number)
|
||||
- `quota`: integer KB (100MB–51200MB in screenshot spec; environment-dependent)
|
||||
- `quotaCode` (optional): campaign code
|
||||
- `expire` (optional): YYYYMMDD
|
||||
|
||||
Notes
|
||||
- Scheduled top-ups use `/mvno/eachQuota/` with `runTime`; immediate uses `/master/addSpec/`.
|
||||
- For development, amounts and gateway can be simulated; production requires real WHMCS gateway configuration.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Real-Time Data Updates
|
||||
|
||||
### Automatic Refresh Mechanism
|
||||
```typescript
|
||||
// After any action (top-up, cancel, etc.)
|
||||
const handleActionSuccess = () => {
|
||||
// Refresh all data
|
||||
refetchSimDetails();
|
||||
refetchUsageData();
|
||||
refetchSubscriptionData();
|
||||
};
|
||||
```
|
||||
|
||||
### Data Consistency
|
||||
- **Immediate Updates**: UI updates optimistically
|
||||
- **Background Sync**: Real data fetched after actions
|
||||
- **Error Handling**: Rollback on API failures
|
||||
- **Loading States**: Visual feedback during operations
|
||||
|
||||
---
|
||||
|
||||
## 📈 Performance Considerations
|
||||
|
||||
### Caching Strategy
|
||||
```typescript
|
||||
// BFF Level Caching
|
||||
- SIM Details: 5 minutes TTL
|
||||
- Usage Data: 1 minute TTL
|
||||
- Subscription Info: 10 minutes TTL
|
||||
|
||||
// Frontend Caching
|
||||
- React Query: 30 seconds stale time
|
||||
- Background refetch: Every 2 minutes
|
||||
```
|
||||
|
||||
### API Optimization
|
||||
- **Batch Requests**: Single endpoint for comprehensive data
|
||||
- **Selective Updates**: Only refresh changed sections
|
||||
- **Error Recovery**: Retry failed requests with exponential backoff
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security & Authentication
|
||||
|
||||
### Authentication Flow
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ BFF │ │ External │
|
||||
│ │ │ │ │ Systems │
|
||||
│ • JWT Token │───▶│ • Validate JWT │───▶│ • WHMCS API Key │
|
||||
│ • User Context │ │ • Map to WHMCS │ │ • Freebit Auth │
|
||||
│ • Permissions │ │ Client ID │ │ • Rate Limiting │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### Data Protection
|
||||
- **Input Validation**: All user inputs sanitized
|
||||
- **Rate Limiting**: API calls throttled per user
|
||||
- **Audit Logging**: All actions logged for compliance
|
||||
- **Error Masking**: Sensitive data not exposed in errors
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring & Analytics
|
||||
|
||||
### Key Metrics Tracked
|
||||
- **API Response Times**: < 500ms target
|
||||
- **Error Rates**: < 1% target
|
||||
- **User Actions**: Top-up frequency, plan changes
|
||||
- **Data Usage Patterns**: Peak usage times, quota consumption
|
||||
|
||||
### Health Checks
|
||||
```typescript
|
||||
// BFF Health Endpoints
|
||||
GET /health/sim-management
|
||||
GET /health/freebit-api
|
||||
GET /health/whmcs-api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
### Planned Improvements
|
||||
1. **Real-time WebSocket Updates**: Live usage data without refresh
|
||||
2. **Advanced Analytics**: Usage predictions and recommendations
|
||||
3. **Bulk Operations**: Manage multiple SIMs simultaneously
|
||||
4. **Mobile App Integration**: Native mobile SIM management
|
||||
|
||||
### Scalability Considerations
|
||||
- **Microservices**: Split BFF into domain-specific services
|
||||
- **CDN Integration**: Cache static SIM data globally
|
||||
- **Database Optimization**: Implement read replicas for usage data
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
1. **API Timeouts**: Check Freebit API status
|
||||
2. **Data Inconsistency**: Verify WHMCS sync
|
||||
3. **Authentication Errors**: Validate JWT tokens
|
||||
4. **Rate Limiting**: Monitor API quotas
|
||||
|
||||
### Debug Endpoints
|
||||
```typescript
|
||||
// Development only
|
||||
GET /api/subscriptions/{id}/sim/debug
|
||||
GET /api/health/sim-management/detailed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 **Summary for Your Managers**
|
||||
|
||||
This comprehensive documentation explains:
|
||||
|
||||
### **🏗️ System Architecture**
|
||||
- **3-Tier Architecture**: Frontend → BFF → External APIs (WHMCS + Freebit)
|
||||
- **Data Aggregation**: BFF combines data from multiple sources
|
||||
- **Real-time Updates**: Automatic refresh after user actions
|
||||
|
||||
### **📊 Key Data Flows**
|
||||
1. **SIM Actions**: Button availability based on SIM type and status
|
||||
2. **SIM Details**: Phone number, data remaining, service status
|
||||
3. **Usage Chart**: Real-time consumption and quota visualization
|
||||
4. **Service Options**: Voice mail, call waiting, roaming settings
|
||||
|
||||
### **🔧 Technical Benefits**
|
||||
- **Performance**: Caching and optimized API calls
|
||||
- **Security**: JWT authentication and input validation
|
||||
- **Reliability**: Error handling and retry mechanisms
|
||||
- **Monitoring**: Health checks and performance metrics
|
||||
|
||||
### **💼 Business Value**
|
||||
- **User Experience**: Real-time data and intuitive interface
|
||||
- **Operational Efficiency**: Automated SIM management operations
|
||||
- **Data Accuracy**: Direct integration with Freebit and WHMCS
|
||||
- **Scalability**: Architecture supports future enhancements
|
||||
|
||||
This documentation will help your managers understand the technical complexity and business value of the SIM Management system!
|
||||
46
docs/SUBSCRIPTION-SERVICE-MANAGEMENT.md
Normal file
46
docs/SUBSCRIPTION-SERVICE-MANAGEMENT.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Subscription Service Management
|
||||
|
||||
Guidance for the unified Service Management area in the Subscriptions detail page. This area provides a dropdown to switch between different service types for a given subscription.
|
||||
|
||||
- Location: `Subscriptions > [Subscription] > Service Management`
|
||||
- Selector: Service dropdown with options: `SIM`, `Internet`, `Netgear`, `VPN`
|
||||
- Current status: `SIM` available now; others are placeholders (coming soon)
|
||||
|
||||
## UI Structure
|
||||
|
||||
```
|
||||
apps/portal/src/features/service-management/
|
||||
├── components/
|
||||
│ └── ServiceManagementSection.tsx # Container with service dropdown
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
- Header: Title + description, service dropdown selector
|
||||
- Body: Renders the active service panel
|
||||
- Default selection: `SIM` for SIM products; otherwise `Internet`
|
||||
|
||||
## Service Panels
|
||||
|
||||
- SIM: Renders the existing SIM management UI
|
||||
- Source: `apps/portal/src/features/sim-management/components/SimManagementSection.tsx`
|
||||
- Backend: `/api/subscriptions/{id}/sim/*`
|
||||
- Internet: Placeholder (coming soon)
|
||||
- Netgear: Placeholder (coming soon)
|
||||
- VPN: Placeholder (coming soon)
|
||||
|
||||
## Integration
|
||||
|
||||
- Entry point: `apps/portal/src/app/subscriptions/[id]/page.tsx` renders `ServiceManagementSection`
|
||||
- Detection: SIM availability is inferred from `subscription.productName` including `sim` (case-insensitive)
|
||||
|
||||
## Future Expansion
|
||||
|
||||
- Replace placeholders with actual feature modules per service type
|
||||
- Gate options per subscription capabilities (disable/hide unsupported services)
|
||||
- Deep-linking: support `?service=sim|internet|netgear|vpn` to preselect a panel
|
||||
- Telemetry: track panel usage and feature adoption
|
||||
|
||||
## Notes
|
||||
|
||||
- This structure avoids breaking changes to the existing SIM workflow while preparing a clean surface for additional services.
|
||||
- SIM documentation remains at `docs/FREEBIT-SIM-MANAGEMENT.md` and is unchanged functionally.
|
||||
Loading…
x
Reference in New Issue
Block a user