Enhance Freebit integration and improve error handling
- Added FreebitMapperService to facilitate account normalization and improve code organization. - Updated FreebitAuthService to streamline error handling and response parsing, replacing custom exceptions with standard error messages. - Enhanced FreebitClientService to ensure proper URL construction and improved logging for API errors. - Refactored FreebitOperationsService to include new request types and validation, ensuring better handling of SIM operations. - Updated FreebitOrchestratorService to utilize the new mapper for account normalization across various methods. - Improved SIM management features in the portal, including better handling of SIM details and usage information. - Refactored components to enhance user experience and maintainability, including updates to the ChangePlanModal and SimActions components.
This commit is contained in:
parent
c8bd3a73d7
commit
9c796f59da
@ -1,14 +1,22 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, forwardRef, Inject, Optional } from "@nestjs/common";
|
||||
import { FreebitOrchestratorService } from "./services/freebit-orchestrator.service";
|
||||
import { FreebitMapperService } from "./services/freebit-mapper.service";
|
||||
import { FreebitOperationsService } from "./services/freebit-operations.service";
|
||||
import { FreebitClientService } from "./services/freebit-client.service";
|
||||
import { FreebitAuthService } from "./services/freebit-auth.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => {
|
||||
const { SimManagementModule } = require("../../modules/subscriptions/sim-management/sim-management.module");
|
||||
return SimManagementModule;
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
// Core services
|
||||
FreebitClientService,
|
||||
FreebitAuthService,
|
||||
FreebitMapperService,
|
||||
FreebitOperationsService,
|
||||
FreebitOrchestratorService,
|
||||
],
|
||||
|
||||
410
apps/bff/src/integrations/freebit/interfaces/freebit.types.ts
Normal file
410
apps/bff/src/integrations/freebit/interfaces/freebit.types.ts
Normal file
@ -0,0 +1,410 @@
|
||||
// Freebit API Type Definitions (cleaned)
|
||||
|
||||
export interface FreebitAuthRequest {
|
||||
oemId: string; // 4-char alphanumeric ISP identifier
|
||||
oemKey: string; // 32-char auth key
|
||||
}
|
||||
|
||||
export interface FreebitAuthResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string | number;
|
||||
};
|
||||
authKey: string; // Token for subsequent API calls
|
||||
}
|
||||
|
||||
export interface FreebitAccountDetailsRequest {
|
||||
authKey: string;
|
||||
version?: string | number; // Docs recommend "2"
|
||||
requestDatas: Array<{
|
||||
kind: "MASTER" | "MVNO";
|
||||
account?: string | number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FreebitAccountDetail {
|
||||
kind: "MASTER" | "MVNO";
|
||||
account: string | number;
|
||||
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
|
||||
status?: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
|
||||
startDate?: string | number;
|
||||
relationCode?: string;
|
||||
resultCode?: string | number;
|
||||
planCode?: string;
|
||||
planName?: string;
|
||||
iccid?: string | number;
|
||||
imsi?: string | number;
|
||||
eid?: string;
|
||||
contractLine?: string;
|
||||
size?: "standard" | "nano" | "micro" | "esim";
|
||||
simSize?: "standard" | "nano" | "micro" | "esim";
|
||||
msisdn?: string | number;
|
||||
sms?: number; // 10=active, 20=inactive
|
||||
talk?: number; // 10=active, 20=inactive
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
quota?: number; // Remaining quota
|
||||
remainingQuotaMb?: string | number | null;
|
||||
remainingQuotaKb?: string | number | null;
|
||||
voicemail?: "10" | "20" | number | null;
|
||||
voiceMail?: "10" | "20" | number | null;
|
||||
callwaiting?: "10" | "20" | number | null;
|
||||
callWaiting?: "10" | "20" | number | null;
|
||||
worldwing?: "10" | "20" | number | null;
|
||||
worldWing?: "10" | "20" | number | null;
|
||||
networkType?: string;
|
||||
async?: { func: string; date: string | number };
|
||||
}
|
||||
|
||||
export interface FreebitAccountDetailsResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string | number;
|
||||
};
|
||||
masterAccount?: string;
|
||||
responseDatas: FreebitAccountDetail[];
|
||||
}
|
||||
|
||||
export interface FreebitTrafficInfoRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
export interface FreebitTrafficInfoResponse {
|
||||
resultCode: string;
|
||||
status: {
|
||||
message: string;
|
||||
statusCode: string | number;
|
||||
};
|
||||
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 FreebitTopUpRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
quota: number; // KB units (e.g., 102400 for 100MB)
|
||||
quotaCode?: string; // Campaign code
|
||||
expire?: string; // YYYYMMDD format (8 digits)
|
||||
runTime?: string; // Scheduled execution time (YYYYMMDD format, 8 digits)
|
||||
}
|
||||
|
||||
export interface FreebitTopUpResponse {
|
||||
resultCode: string;
|
||||
status: { message: string; statusCode: string | number };
|
||||
}
|
||||
|
||||
// AddSpec request for updating SIM options/features immediately
|
||||
export interface FreebitAddSpecRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
kind?: string; // e.g. 'MVNO'
|
||||
// Feature flags: 10 = enabled, 20 = disabled
|
||||
voiceMail?: "10" | "20";
|
||||
voicemail?: "10" | "20";
|
||||
callWaiting?: "10" | "20";
|
||||
callwaiting?: "10" | "20";
|
||||
worldWing?: "10" | "20";
|
||||
worldwing?: "10" | "20";
|
||||
contractLine?: string; // '4G' or '5G'
|
||||
}
|
||||
|
||||
export interface FreebitVoiceOptionSettings {
|
||||
voiceMail?: "10" | "20";
|
||||
callWaiting?: "10" | "20";
|
||||
callTransfer?: "10" | "20";
|
||||
callTransferWorld?: "10" | "20";
|
||||
callTransferNoId?: "10" | "20";
|
||||
worldCall?: "10" | "20";
|
||||
worldCallCreditLimit?: string;
|
||||
worldWing?: "10" | "20";
|
||||
worldWingCreditLimit?: string;
|
||||
}
|
||||
|
||||
export interface FreebitVoiceOptionRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
userConfirmed?: "10" | "20";
|
||||
aladinOperated?: "10" | "20";
|
||||
talkOption: FreebitVoiceOptionSettings;
|
||||
}
|
||||
|
||||
export interface FreebitVoiceOptionResponse {
|
||||
resultCode: string;
|
||||
status: { message: string; statusCode: string | number };
|
||||
}
|
||||
|
||||
export interface FreebitAddSpecResponse {
|
||||
resultCode: string;
|
||||
status: { message: string; statusCode: string | number };
|
||||
}
|
||||
|
||||
export interface FreebitQuotaHistoryRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
}
|
||||
|
||||
export interface FreebitQuotaHistoryItem {
|
||||
quota: string; // KB as string
|
||||
date: string;
|
||||
expire: string;
|
||||
quotaCode: string;
|
||||
}
|
||||
|
||||
export interface FreebitQuotaHistoryResponse {
|
||||
resultCode: string;
|
||||
status: { message: string; statusCode: string | number };
|
||||
total: string | number;
|
||||
count: string | number;
|
||||
quotaHistory: FreebitQuotaHistoryItem[];
|
||||
}
|
||||
|
||||
export interface FreebitPlanChangeRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
planCode: string; // Note: API expects camelCase "planCode" not "plancode"
|
||||
globalip?: "0" | "1"; // 0=disabled, 1=assign global IP (PA05-21 expects legacy flags)
|
||||
runTime?: string; // YYYYMMDD format (8 digits, date only) - optional
|
||||
contractLine?: "4G" | "5G"; // Network type for contract line changes
|
||||
}
|
||||
|
||||
export interface FreebitPlanChangeResponse {
|
||||
resultCode: string;
|
||||
status: { message: string; statusCode: string | number };
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
}
|
||||
|
||||
export interface FreebitPlanChangePayload {
|
||||
requestDatas: Array<{
|
||||
kind: "MVNO";
|
||||
account: string;
|
||||
newPlanCode: string;
|
||||
assignGlobalIp: boolean;
|
||||
scheduledAt?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FreebitAddSpecPayload {
|
||||
requestDatas: Array<{
|
||||
kind: "MVNO";
|
||||
account: string;
|
||||
specCode: string;
|
||||
enabled?: boolean;
|
||||
networkType?: "4G" | "5G";
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FreebitCancelPlanPayload {
|
||||
requestDatas: Array<{
|
||||
kind: "MVNO";
|
||||
account: string;
|
||||
runDate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FreebitEsimReissuePayload {
|
||||
requestDatas: Array<{
|
||||
kind: "MVNO";
|
||||
account: string;
|
||||
newEid: string;
|
||||
oldEid?: string;
|
||||
planCode?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FreebitContractLineChangeRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
contractLine: "4G" | "5G";
|
||||
productNumber?: string;
|
||||
eid?: string;
|
||||
}
|
||||
|
||||
export interface FreebitContractLineChangeResponse {
|
||||
resultCode: string | number;
|
||||
status?: { message?: string; statusCode?: string | number };
|
||||
statusCode?: string | number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface FreebitCancelPlanRequest {
|
||||
authKey: string;
|
||||
account: string;
|
||||
runTime?: string; // YYYYMMDD - optional
|
||||
}
|
||||
|
||||
export interface FreebitCancelPlanResponse {
|
||||
resultCode: string;
|
||||
status: { message: string; statusCode: string | number };
|
||||
}
|
||||
|
||||
// PA02-04: Account Cancellation (master/cnclAcnt)
|
||||
export interface FreebitCancelAccountRequest {
|
||||
authKey: string;
|
||||
kind: string; // e.g., 'MVNO'
|
||||
account: string;
|
||||
runDate?: string; // YYYYMMDD
|
||||
}
|
||||
|
||||
export interface FreebitCancelAccountResponse {
|
||||
resultCode: string;
|
||||
status: { message: string; statusCode: string | number };
|
||||
}
|
||||
|
||||
export interface FreebitEsimReissueRequest {
|
||||
authKey: string;
|
||||
requestDatas: Array<{
|
||||
kind: "MVNO";
|
||||
account: string;
|
||||
newEid?: string;
|
||||
oldEid?: string;
|
||||
planCode?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FreebitEsimReissueResponse {
|
||||
resultCode: string;
|
||||
status: { message: string; statusCode: string | number };
|
||||
}
|
||||
|
||||
export interface FreebitEsimAddAccountRequest {
|
||||
authKey: string;
|
||||
aladinOperated: string; // '10' for issue, '20' for no-issue
|
||||
account: string;
|
||||
eid: string;
|
||||
addKind: "N" | "R"; // N = new, R = reissue
|
||||
shipDate?: string;
|
||||
planCode?: string;
|
||||
contractLine?: string;
|
||||
mnp?: {
|
||||
reserveNumber: string;
|
||||
reserveExpireDate: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FreebitEsimAddAccountResponse {
|
||||
resultCode: string;
|
||||
status: { message: string; statusCode: string | number };
|
||||
}
|
||||
|
||||
// PA05-41 eSIM Account Activation (addAcct)
|
||||
// Based on Freebit API specification - all parameters from JSON table
|
||||
export interface FreebitEsimAccountActivationRequest {
|
||||
authKey: string; // Row 1: 認証キー (Required)
|
||||
aladinOperated: string; // Row 2: ALADIN帳票作成フラグ ('10':操作済, '20':未操作) (Required)
|
||||
masterAccount?: string; // Row 3: マスタアカウント (Conditional - for service provider)
|
||||
masterPassword?: string; // Row 4: マスタパスワード (Conditional - for service provider)
|
||||
createType: string; // Row 5: 登録区分 ('new', 'reissue', 'add') (Required)
|
||||
eid?: string; // Row 6: eSIM識別番号 (Conditional - required for reissue/exchange)
|
||||
account: string; // Row 7: アカウント/MSISDN (Required)
|
||||
simkind: string; // Row 8: SIM種別 (Conditional - Required except when addKind='R')
|
||||
// eSIM: 'E0':音声あり, 'E2':SMSなし, 'E3':SMSあり
|
||||
// Physical: '3MS', '3MR', etc
|
||||
contractLine?: string; // Row 9: 契約回線種別 ('4G', '5G') (Conditional)
|
||||
repAccount?: string; // Row 10: 代表番号 (Conditional)
|
||||
addKind?: string; // Row 11: 開通種別 ('N':新規, 'M':MNP転入, 'R':再発行) (Required)
|
||||
reissue?: string; // Row 12: 再発行情報 (Conditional)
|
||||
oldProductNumber?: string; // Row 13: 元製造番号 (Conditional - for exchange)
|
||||
oldEid?: string; // Row 14: 元eSIM識別番号 (Conditional - for exchange)
|
||||
mnp?: { // Row 15: MNP情報 (Conditional)
|
||||
reserveNumber: string; // Row 16: MNP予約番号 (Conditional)
|
||||
reserveExpireDate?: string; // (Conditional) YYYYMMDD
|
||||
};
|
||||
firstnameKanji?: string; // Row 17: 由字(漢字) (Conditional)
|
||||
lastnameKanji?: string; // Row 18: 名前(漢字) (Conditional)
|
||||
firstnameZenKana?: string; // Row 19: 由字(全角カタカナ) (Conditional)
|
||||
lastnameZenKana?: string; // Row 20: 名前(全角カタカナ) (Conditional)
|
||||
gender?: string; // Row 21: 性別 ('M', 'F') (Required for identification)
|
||||
birthday?: string; // Row 22: 生年月日 YYYYMMDD (Conditional)
|
||||
shipDate?: string; // Row 23: 出荷日 YYYYMMDD (Conditional)
|
||||
planCode?: string; // Row 24: プランコード (Max 32 chars) (Conditional)
|
||||
deliveryCode?: string; // Row 25: 顧客コード (Max 10 chars) (Conditional - OEM specific)
|
||||
globalIp?: string; // Additional: グローバルIP ('10': なし, '20': あり)
|
||||
size?: string; // SIM physical size (for physical SIMs)
|
||||
}
|
||||
|
||||
export interface FreebitEsimAccountActivationResponse {
|
||||
resultCode: string;
|
||||
status?: {
|
||||
message?: string;
|
||||
statusCode?: string | number;
|
||||
};
|
||||
statusCode?: string | number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Portal-specific types for SIM management
|
||||
export interface SimDetails {
|
||||
account: string;
|
||||
status: "active" | "suspended" | "cancelled" | "pending";
|
||||
planCode: string;
|
||||
planName: string;
|
||||
simType: "standard" | "nano" | "micro" | "esim";
|
||||
iccid: string;
|
||||
eid: string;
|
||||
msisdn: string;
|
||||
imsi: string;
|
||||
remainingQuotaMb: number;
|
||||
remainingQuotaKb: number;
|
||||
voiceMailEnabled: boolean;
|
||||
callWaitingEnabled: boolean;
|
||||
internationalRoamingEnabled: boolean;
|
||||
networkType: string;
|
||||
activatedAt?: string;
|
||||
expiresAt?: string;
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
startDate?: string;
|
||||
hasVoice?: boolean;
|
||||
hasSms?: boolean;
|
||||
}
|
||||
|
||||
export interface SimUsage {
|
||||
account: string;
|
||||
todayUsageMb: number;
|
||||
todayUsageKb: number;
|
||||
monthlyUsageMb?: number;
|
||||
monthlyUsageKb?: number;
|
||||
recentDaysUsage: Array<{ date: string; usageKb: number; usageMb: number }>;
|
||||
isBlacklisted: boolean;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
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 FreebitError extends Error {
|
||||
resultCode: string;
|
||||
statusCode: string | number;
|
||||
freebititMessage: string;
|
||||
}
|
||||
|
||||
// Configuration
|
||||
export interface FreebitConfig {
|
||||
baseUrl: string;
|
||||
oemId: string;
|
||||
oemKey: string;
|
||||
timeout: number;
|
||||
retryAttempts: number;
|
||||
detailsEndpoint?: string;
|
||||
}
|
||||
@ -2,23 +2,13 @@ import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions";
|
||||
import type {
|
||||
AuthRequest as FreebitAuthRequest,
|
||||
AuthResponse as FreebitAuthResponse,
|
||||
} from "@customer-portal/domain/sim/providers/freebit";
|
||||
import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit";
|
||||
FreebitConfig,
|
||||
FreebitAuthRequest,
|
||||
FreebitAuthResponse,
|
||||
} from "../interfaces/freebit.types";
|
||||
import { FreebitError } from "./freebit-error.service";
|
||||
|
||||
interface FreebitConfig {
|
||||
baseUrl: string;
|
||||
oemId: string;
|
||||
oemKey: string;
|
||||
timeout: number;
|
||||
retryAttempts: number;
|
||||
detailsEndpoint?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FreebitAuthService {
|
||||
private readonly config: FreebitConfig;
|
||||
@ -67,41 +57,39 @@ export class FreebitAuthService {
|
||||
|
||||
try {
|
||||
if (!this.config.oemKey) {
|
||||
throw new FreebitOperationException(
|
||||
"Freebit API not configured: FREEBIT_OEM_KEY is missing",
|
||||
{
|
||||
operation: "authenticate",
|
||||
}
|
||||
);
|
||||
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
|
||||
}
|
||||
|
||||
const request: FreebitAuthRequest = FreebitProvider.schemas.auth.parse({
|
||||
const request: FreebitAuthRequest = {
|
||||
oemId: this.config.oemId,
|
||||
oemKey: this.config.oemKey,
|
||||
});
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
|
||||
// Ensure proper URL construction - remove double slashes
|
||||
const baseUrl = this.config.baseUrl.replace(/\/$/, '');
|
||||
const authUrl = `${baseUrl}/authOem/`;
|
||||
|
||||
const response = await fetch(authUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `json=${JSON.stringify(request)}`,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new FreebitOperationException(`HTTP ${response.status}: ${response.statusText}`, {
|
||||
operation: "authenticate",
|
||||
status: response.status,
|
||||
});
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const json: unknown = await response.json();
|
||||
const data: FreebitAuthResponse = FreebitProvider.mapper.transformFreebitAuthResponse(json);
|
||||
const data = (await response.json()) as FreebitAuthResponse;
|
||||
const resultCode = data?.resultCode != null ? String(data.resultCode).trim() : undefined;
|
||||
const statusCode =
|
||||
data?.status?.statusCode != null ? String(data.status.statusCode).trim() : undefined;
|
||||
|
||||
if (data.resultCode !== "100" || !data.authKey) {
|
||||
if (resultCode !== "100") {
|
||||
throw new FreebitError(
|
||||
`Authentication failed: ${data.status?.message ?? "Unknown error"}`,
|
||||
data.resultCode,
|
||||
data.status?.statusCode,
|
||||
data.status?.message
|
||||
`Authentication failed: ${data.status.message}`,
|
||||
resultCode,
|
||||
statusCode,
|
||||
data.status.message
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -30,7 +30,10 @@ export class FreebitClientService {
|
||||
const config = this.authService.getConfig();
|
||||
|
||||
const requestPayload = { ...payload, authKey };
|
||||
const url = `${config.baseUrl}${endpoint}`;
|
||||
// Ensure proper URL construction - remove double slashes
|
||||
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
const url = `${baseUrl}${cleanEndpoint}`;
|
||||
|
||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||
try {
|
||||
@ -52,6 +55,15 @@ export class FreebitClientService {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "Unable to read response body");
|
||||
this.logger.error(`Freebit API HTTP error`, {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
responseBody: errorText,
|
||||
attempt,
|
||||
payload: this.sanitizePayload(requestPayload),
|
||||
});
|
||||
throw new FreebitError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status.toString()
|
||||
@ -60,18 +72,29 @@ export class FreebitClientService {
|
||||
|
||||
const responseData = (await response.json()) as TResponse;
|
||||
|
||||
if (responseData.resultCode && responseData.resultCode !== "100") {
|
||||
const resultCode = this.normalizeResultCode(responseData.resultCode);
|
||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||
|
||||
if (resultCode && resultCode !== "100") {
|
||||
this.logger.warn("Freebit API returned error response", {
|
||||
url,
|
||||
resultCode,
|
||||
statusCode,
|
||||
statusMessage: responseData.status?.message,
|
||||
fullResponse: responseData,
|
||||
});
|
||||
|
||||
throw new FreebitError(
|
||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||
responseData.resultCode,
|
||||
responseData.status?.statusCode,
|
||||
resultCode,
|
||||
statusCode,
|
||||
responseData.status?.message
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug("Freebit API request successful", {
|
||||
url,
|
||||
resultCode: responseData.resultCode,
|
||||
resultCode,
|
||||
});
|
||||
|
||||
return responseData;
|
||||
@ -117,7 +140,10 @@ export class FreebitClientService {
|
||||
TPayload extends object,
|
||||
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
||||
const config = this.authService.getConfig();
|
||||
const url = `${config.baseUrl}${endpoint}`;
|
||||
// Ensure proper URL construction - remove double slashes
|
||||
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
const url = `${baseUrl}${cleanEndpoint}`;
|
||||
|
||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||
try {
|
||||
@ -147,18 +173,29 @@ export class FreebitClientService {
|
||||
|
||||
const responseData = (await response.json()) as TResponse;
|
||||
|
||||
if (responseData.resultCode && responseData.resultCode !== "100") {
|
||||
const resultCode = this.normalizeResultCode(responseData.resultCode);
|
||||
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||
|
||||
if (resultCode && resultCode !== "100") {
|
||||
this.logger.error(`Freebit API returned error result code`, {
|
||||
url,
|
||||
resultCode,
|
||||
statusCode,
|
||||
message: responseData.status?.message,
|
||||
responseData: this.sanitizePayload(responseData as unknown as Record<string, unknown>),
|
||||
attempt,
|
||||
});
|
||||
throw new FreebitError(
|
||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||
responseData.resultCode,
|
||||
responseData.status?.statusCode,
|
||||
resultCode,
|
||||
statusCode,
|
||||
responseData.status?.message
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug("Freebit JSON API request successful", {
|
||||
url,
|
||||
resultCode: responseData.resultCode,
|
||||
resultCode,
|
||||
});
|
||||
|
||||
return responseData;
|
||||
@ -204,7 +241,10 @@ export class FreebitClientService {
|
||||
*/
|
||||
async makeSimpleRequest(endpoint: string): Promise<boolean> {
|
||||
const config = this.authService.getConfig();
|
||||
const url = `${config.baseUrl}${endpoint}`;
|
||||
// Ensure proper URL construction - remove double slashes
|
||||
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
const url = `${baseUrl}${cleanEndpoint}`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
@ -243,4 +283,13 @@ export class FreebitClientService {
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private normalizeResultCode(code?: string | number | null): string | undefined {
|
||||
if (code === undefined || code === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = String(code).trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +81,19 @@ export class FreebitError extends Error {
|
||||
return "SIM service request timed out. Please try again.";
|
||||
}
|
||||
|
||||
// Specific error codes
|
||||
if (this.resultCode === "215" || this.statusCode === "215") {
|
||||
return "Plan change failed. This may be due to: (1) Account has existing scheduled operations, (2) Invalid plan code for this account, (3) Account restrictions. Please check the Freebit Partner Tools for account status or contact support.";
|
||||
}
|
||||
|
||||
if (this.resultCode === "381" || this.statusCode === "381") {
|
||||
return "Network type change rejected. The current plan does not allow switching to the requested contract line. Adjust the plan first or contact support.";
|
||||
}
|
||||
|
||||
if (this.resultCode === "382" || this.statusCode === "382") {
|
||||
return "Network type change rejected because the contract line is not eligible for modification at this time. Please verify the SIM's status in Freebit before retrying.";
|
||||
}
|
||||
|
||||
return "SIM operation failed. Please try again or contact support.";
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,247 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type {
|
||||
FreebitAccountDetailsResponse,
|
||||
FreebitTrafficInfoResponse,
|
||||
FreebitQuotaHistoryResponse,
|
||||
SimDetails,
|
||||
SimUsage,
|
||||
SimTopUpHistory,
|
||||
} from "../interfaces/freebit.types";
|
||||
import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service";
|
||||
|
||||
@Injectable()
|
||||
export class FreebitMapperService {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService
|
||||
) {}
|
||||
|
||||
private parseOptionFlag(value: unknown, defaultValue: boolean = false): boolean {
|
||||
// If value is undefined or null, return the default
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value === 10 || value === 1;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "on" || normalized === "true") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "off" || normalized === "false") {
|
||||
return false;
|
||||
}
|
||||
const numeric = Number(normalized);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
return numeric === 10 || numeric === 1;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map SIM status from Freebit API to domain status
|
||||
*/
|
||||
mapSimStatus(status: string): "active" | "suspended" | "cancelled" | "pending" {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "active";
|
||||
case "suspended":
|
||||
return "suspended";
|
||||
case "temporary":
|
||||
case "waiting":
|
||||
return "pending";
|
||||
case "obsolete":
|
||||
return "cancelled";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Freebit account details response to SimDetails
|
||||
*/
|
||||
async mapToSimDetails(response: FreebitAccountDetailsResponse): Promise<SimDetails> {
|
||||
const account = response.responseDatas[0];
|
||||
if (!account) {
|
||||
throw new Error("No account data in response");
|
||||
}
|
||||
|
||||
let simType: "standard" | "nano" | "micro" | "esim" = "standard";
|
||||
if (account.eid) {
|
||||
simType = "esim";
|
||||
} else if (account.simSize) {
|
||||
simType = account.simSize;
|
||||
}
|
||||
|
||||
// Try to get voice options from database first
|
||||
let voiceMailEnabled = true;
|
||||
let callWaitingEnabled = true;
|
||||
let internationalRoamingEnabled = true;
|
||||
let networkType = String(account.networkType ?? account.contractLine ?? "4G");
|
||||
|
||||
if (this.voiceOptionsService) {
|
||||
try {
|
||||
const storedOptions = await this.voiceOptionsService.getVoiceOptions(
|
||||
String(account.account ?? "")
|
||||
);
|
||||
|
||||
if (storedOptions) {
|
||||
voiceMailEnabled = storedOptions.voiceMailEnabled;
|
||||
callWaitingEnabled = storedOptions.callWaitingEnabled;
|
||||
internationalRoamingEnabled = storedOptions.internationalRoamingEnabled;
|
||||
networkType = storedOptions.networkType;
|
||||
|
||||
this.logger.debug("[FreebitMapper] Loaded voice options from database", {
|
||||
account: account.account,
|
||||
options: storedOptions,
|
||||
});
|
||||
} else {
|
||||
// No stored options, check API response
|
||||
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, true);
|
||||
callWaitingEnabled = this.parseOptionFlag(
|
||||
account.callwaiting ?? account.callWaiting,
|
||||
true
|
||||
);
|
||||
internationalRoamingEnabled = this.parseOptionFlag(
|
||||
account.worldwing ?? account.worldWing,
|
||||
true
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
"[FreebitMapper] No stored options found, using defaults or API values",
|
||||
{
|
||||
account: account.account,
|
||||
voiceMailEnabled,
|
||||
callWaitingEnabled,
|
||||
internationalRoamingEnabled,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn("[FreebitMapper] Failed to load voice options from database", {
|
||||
account: account.account,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
account: String(account.account ?? ""),
|
||||
status: this.mapSimStatus(String(account.state ?? account.status ?? "pending")),
|
||||
planCode: String(account.planCode ?? ""),
|
||||
planName: String(account.planName ?? ""),
|
||||
simType,
|
||||
iccid: String(account.iccid ?? ""),
|
||||
eid: String(account.eid ?? ""),
|
||||
msisdn: String(account.msisdn ?? account.account ?? ""),
|
||||
imsi: String(account.imsi ?? ""),
|
||||
remainingQuotaMb: Number(account.remainingQuotaMb ?? account.quota ?? 0),
|
||||
remainingQuotaKb: Number(account.remainingQuotaKb ?? 0),
|
||||
voiceMailEnabled,
|
||||
callWaitingEnabled,
|
||||
internationalRoamingEnabled,
|
||||
networkType,
|
||||
activatedAt: account.startDate ? String(account.startDate) : undefined,
|
||||
expiresAt: account.async ? String(account.async.date) : undefined,
|
||||
ipv4: account.ipv4,
|
||||
ipv6: account.ipv6,
|
||||
startDate: account.startDate ? String(account.startDate) : undefined,
|
||||
hasVoice: account.talk === 10,
|
||||
hasSms: account.sms === 10,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Freebit traffic info response to SimUsage
|
||||
*/
|
||||
mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage {
|
||||
if (!response.traffic) {
|
||||
throw new Error("No traffic data in response");
|
||||
}
|
||||
|
||||
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) || 0) / 1024) * 100) / 100,
|
||||
}));
|
||||
|
||||
return {
|
||||
account: String(response.account ?? ""),
|
||||
todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100,
|
||||
todayUsageKb,
|
||||
recentDaysUsage: recentDaysData,
|
||||
isBlacklisted: response.traffic.blackList === "10",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Freebit quota history response to SimTopUpHistory
|
||||
*/
|
||||
mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory {
|
||||
if (!response.quotaHistory) {
|
||||
throw new Error("No history data in response");
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
totalAdditions: Number(response.total) || 0,
|
||||
additionCount: Number(response.count) || 0,
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize account identifier (remove formatting)
|
||||
*/
|
||||
normalizeAccount(account: string): string {
|
||||
return account.replace(/[-\s()]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate account format
|
||||
*/
|
||||
validateAccount(account: string): boolean {
|
||||
const normalized = this.normalizeAccount(account);
|
||||
// Basic validation - should be digits, typically 10-11 digits for Japanese phone numbers
|
||||
return /^\d{10,11}$/.test(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for Freebit API (YYYYMMDD)
|
||||
*/
|
||||
formatDateForApi(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse date from Freebit API format (YYYYMMDD)
|
||||
*/
|
||||
parseDateFromApi(dateString: string): Date | null {
|
||||
if (!/^\d{8}$/.test(dateString)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = parseInt(dateString.substring(0, 4), 10);
|
||||
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed
|
||||
const day = parseInt(dateString.substring(6, 8), 10);
|
||||
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
}
|
||||
@ -1,54 +1,131 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions";
|
||||
import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
|
||||
import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit";
|
||||
import { FreebitClientService } from "./freebit-client.service";
|
||||
import { FreebitMapperService } from "./freebit-mapper.service";
|
||||
import { FreebitAuthService } from "./freebit-auth.service";
|
||||
|
||||
// Type imports from domain (following clean import pattern from README)
|
||||
import type {
|
||||
TopUpResponse,
|
||||
PlanChangeResponse,
|
||||
AddSpecResponse,
|
||||
CancelPlanResponse,
|
||||
EsimReissueResponse,
|
||||
EsimAddAccountResponse,
|
||||
EsimActivationResponse,
|
||||
QuotaHistoryRequest,
|
||||
FreebitTopUpRequest,
|
||||
FreebitPlanChangeRequest,
|
||||
FreebitCancelPlanRequest,
|
||||
FreebitEsimReissueRequest,
|
||||
FreebitEsimActivationRequest,
|
||||
FreebitEsimActivationParams,
|
||||
FreebitAccountDetailsRequest,
|
||||
FreebitAccountDetailsResponse,
|
||||
FreebitTrafficInfoRequest,
|
||||
FreebitTrafficInfoResponse,
|
||||
FreebitTopUpRequest,
|
||||
FreebitTopUpResponse,
|
||||
FreebitQuotaHistoryRequest,
|
||||
FreebitQuotaHistoryResponse,
|
||||
FreebitPlanChangeRequest,
|
||||
FreebitPlanChangeResponse,
|
||||
FreebitContractLineChangeRequest,
|
||||
FreebitContractLineChangeResponse,
|
||||
FreebitAddSpecRequest,
|
||||
FreebitAddSpecResponse,
|
||||
FreebitVoiceOptionSettings,
|
||||
FreebitVoiceOptionRequest,
|
||||
FreebitVoiceOptionResponse,
|
||||
FreebitCancelPlanRequest,
|
||||
FreebitCancelPlanResponse,
|
||||
FreebitEsimReissueRequest,
|
||||
FreebitEsimReissueResponse,
|
||||
FreebitEsimAddAccountRequest,
|
||||
FreebitAccountDetailsRaw,
|
||||
FreebitTrafficInfoRaw,
|
||||
FreebitQuotaHistoryRaw,
|
||||
} from "@customer-portal/domain/sim/providers/freebit";
|
||||
FreebitEsimAddAccountResponse,
|
||||
FreebitEsimAccountActivationRequest,
|
||||
FreebitEsimAccountActivationResponse,
|
||||
SimDetails,
|
||||
SimUsage,
|
||||
SimTopUpHistory,
|
||||
} from "../interfaces/freebit.types";
|
||||
|
||||
@Injectable()
|
||||
export class FreebitOperationsService {
|
||||
constructor(
|
||||
private readonly client: FreebitClientService,
|
||||
private readonly mapper: FreebitMapperService,
|
||||
private readonly auth: FreebitAuthService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: any
|
||||
) {}
|
||||
|
||||
private readonly operationTimestamps = new Map<
|
||||
string,
|
||||
{
|
||||
voice?: number;
|
||||
network?: number;
|
||||
plan?: number;
|
||||
cancellation?: number;
|
||||
}
|
||||
>();
|
||||
|
||||
private getOperationWindow(account: string) {
|
||||
if (!this.operationTimestamps.has(account)) {
|
||||
this.operationTimestamps.set(account, {});
|
||||
}
|
||||
return this.operationTimestamps.get(account)!;
|
||||
}
|
||||
|
||||
private assertOperationSpacing(account: string, op: "voice" | "network" | "plan") {
|
||||
const windowMs = 30 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const entry = this.getOperationWindow(account);
|
||||
|
||||
if (op === "voice") {
|
||||
if (entry.plan && now - entry.plan < windowMs) {
|
||||
throw new BadRequestException(
|
||||
"Voice feature changes must be at least 30 minutes apart from plan changes. Please try again later."
|
||||
);
|
||||
}
|
||||
if (entry.network && now - entry.network < windowMs) {
|
||||
throw new BadRequestException(
|
||||
"Voice feature changes must be at least 30 minutes apart from network type updates. Please try again later."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (op === "network") {
|
||||
if (entry.voice && now - entry.voice < windowMs) {
|
||||
throw new BadRequestException(
|
||||
"Network type updates must be requested 30 minutes after voice option changes. Please try again later."
|
||||
);
|
||||
}
|
||||
if (entry.plan && now - entry.plan < windowMs) {
|
||||
throw new BadRequestException(
|
||||
"Network type updates must be requested at least 30 minutes apart from plan changes. Please try again later."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (op === "plan") {
|
||||
if (entry.voice && now - entry.voice < windowMs) {
|
||||
throw new BadRequestException(
|
||||
"Plan changes must be requested 30 minutes after voice option changes. Please try again later."
|
||||
);
|
||||
}
|
||||
if (entry.network && now - entry.network < windowMs) {
|
||||
throw new BadRequestException(
|
||||
"Plan changes must be requested 30 minutes after network type updates. Please try again later."
|
||||
);
|
||||
}
|
||||
if (entry.cancellation) {
|
||||
throw new BadRequestException(
|
||||
"This subscription has a pending cancellation. Plan changes are no longer permitted."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private stampOperation(account: string, op: "voice" | "network" | "plan" | "cancellation") {
|
||||
const entry = this.getOperationWindow(account);
|
||||
entry[op] = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SIM account details with endpoint fallback
|
||||
*/
|
||||
async getSimDetails(account: string): Promise<SimDetails> {
|
||||
try {
|
||||
const request: FreebitAccountDetailsRequest = FreebitProvider.schemas.accountDetails.parse({
|
||||
const request: Omit<FreebitAccountDetailsRequest, "authKey"> = {
|
||||
version: "2",
|
||||
requestDatas: [{ kind: "MVNO", account }],
|
||||
});
|
||||
};
|
||||
|
||||
const config = this.auth.getConfig();
|
||||
const configured = config.detailsEndpoint || "/master/getAcnt/";
|
||||
@ -73,7 +150,7 @@ export class FreebitOperationsService {
|
||||
])
|
||||
);
|
||||
|
||||
let response: FreebitAccountDetailsRaw | undefined;
|
||||
let response: FreebitAccountDetailsResponse | undefined;
|
||||
let lastError: unknown;
|
||||
|
||||
for (const ep of candidates) {
|
||||
@ -82,7 +159,7 @@ export class FreebitOperationsService {
|
||||
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
||||
}
|
||||
response = await this.client.makeAuthenticatedRequest<
|
||||
FreebitAccountDetailsRaw,
|
||||
FreebitAccountDetailsResponse,
|
||||
typeof request
|
||||
>(ep, request);
|
||||
break;
|
||||
@ -98,14 +175,10 @@ export class FreebitOperationsService {
|
||||
if (lastError instanceof Error) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new FreebitOperationException("Failed to get SIM details from any endpoint", {
|
||||
operation: "getSimDetails",
|
||||
account,
|
||||
attemptedEndpoints: ["simDetailsHiho", "simDetailsGet"],
|
||||
});
|
||||
throw new Error("Failed to get SIM details from any endpoint");
|
||||
}
|
||||
|
||||
return FreebitProvider.transformFreebitAccountDetails(response);
|
||||
return await this.mapper.mapToSimDetails(response);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
||||
@ -121,16 +194,14 @@ export class FreebitOperationsService {
|
||||
*/
|
||||
async getSimUsage(account: string): Promise<SimUsage> {
|
||||
try {
|
||||
const request: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({
|
||||
account,
|
||||
});
|
||||
const request: Omit<FreebitTrafficInfoRequest, "authKey"> = { account };
|
||||
|
||||
const response = await this.client.makeAuthenticatedRequest<
|
||||
FreebitTrafficInfoRaw,
|
||||
FreebitTrafficInfoResponse,
|
||||
typeof request
|
||||
>("/mvno/getTrafficInfo/", request);
|
||||
|
||||
return FreebitProvider.transformFreebitTrafficInfo(response);
|
||||
return this.mapper.mapToSimUsage(response);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to get SIM usage for account ${account}`, {
|
||||
@ -150,26 +221,22 @@ export class FreebitOperationsService {
|
||||
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const payload: FreebitTopUpRequest = FreebitProvider.schemas.topUp.parse({
|
||||
const quotaKb = Math.round(quotaMb * 1024);
|
||||
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
|
||||
account,
|
||||
quotaMb,
|
||||
options,
|
||||
});
|
||||
const quotaKb = Math.round(payload.quotaMb * 1024);
|
||||
const baseRequest = {
|
||||
account: payload.account,
|
||||
quota: quotaKb,
|
||||
quotaCode: payload.options?.campaignCode,
|
||||
expire: payload.options?.expiryDate,
|
||||
quotaCode: options.campaignCode,
|
||||
expire: options.expiryDate,
|
||||
};
|
||||
|
||||
const scheduled = Boolean(payload.options?.scheduledAt);
|
||||
const scheduled = !!options.scheduledAt;
|
||||
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
||||
const request = scheduled
|
||||
? { ...baseRequest, runTime: payload.options?.scheduledAt }
|
||||
: baseRequest;
|
||||
const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest;
|
||||
|
||||
await this.client.makeAuthenticatedRequest<TopUpResponse, typeof request>(endpoint, request);
|
||||
await this.client.makeAuthenticatedRequest<FreebitTopUpResponse, typeof request>(
|
||||
endpoint,
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, {
|
||||
account,
|
||||
@ -198,18 +265,18 @@ export class FreebitOperationsService {
|
||||
toDate: string
|
||||
): Promise<SimTopUpHistory> {
|
||||
try {
|
||||
const request: FreebitQuotaHistoryRequest = FreebitProvider.schemas.quotaHistory.parse({
|
||||
const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
|
||||
account,
|
||||
fromDate,
|
||||
toDate,
|
||||
});
|
||||
};
|
||||
|
||||
const response = await this.client.makeAuthenticatedRequest<
|
||||
FreebitQuotaHistoryRaw,
|
||||
QuotaHistoryRequest
|
||||
FreebitQuotaHistoryResponse,
|
||||
typeof request
|
||||
>("/mvno/getQuotaHistory/", request);
|
||||
|
||||
return FreebitProvider.transformFreebitQuotaHistory(response, account);
|
||||
return this.mapper.mapToSimTopUpHistory(response, account);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
|
||||
@ -224,42 +291,92 @@ export class FreebitOperationsService {
|
||||
|
||||
/**
|
||||
* Change SIM plan
|
||||
* Uses PA05-21 changePlan endpoint
|
||||
*
|
||||
* IMPORTANT CONSTRAINTS:
|
||||
* - Requires runTime parameter set to 1st of following month (YYYYMMDDHHmm format)
|
||||
* - Does NOT take effect immediately (unlike PA05-06 and PA05-38)
|
||||
* - Must be done AFTER PA05-06 and PA05-38 (with 30-minute gaps)
|
||||
* - Cannot coexist with PA02-04 (cancellation) - plan changes will cancel the cancellation
|
||||
* - Must run 30 minutes apart from PA05-06 and PA05-38
|
||||
*/
|
||||
async changeSimPlan(
|
||||
account: string,
|
||||
newPlanCode: string,
|
||||
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||
// Import and validate with the schema
|
||||
const parsed: FreebitPlanChangeRequest = FreebitProvider.schemas.planChange.parse({
|
||||
account,
|
||||
newPlanCode,
|
||||
assignGlobalIp: options.assignGlobalIp,
|
||||
scheduledAt: options.scheduledAt,
|
||||
});
|
||||
|
||||
try {
|
||||
const request = {
|
||||
account: parsed.account,
|
||||
plancode: parsed.newPlanCode,
|
||||
globalip: parsed.assignGlobalIp ? "1" : "0",
|
||||
runTime: parsed.scheduledAt,
|
||||
this.assertOperationSpacing(account, "plan");
|
||||
// First, get current SIM details to log for debugging
|
||||
let currentPlanCode: string | undefined;
|
||||
try {
|
||||
const simDetails = await this.getSimDetails(account);
|
||||
currentPlanCode = simDetails.planCode;
|
||||
this.logger.log(`Current SIM plan details before change`, {
|
||||
account,
|
||||
currentPlanCode: simDetails.planCode,
|
||||
status: simDetails.status,
|
||||
simType: simDetails.simType,
|
||||
});
|
||||
} catch (detailsError) {
|
||||
this.logger.warn(`Could not fetch current SIM details`, {
|
||||
account,
|
||||
error: getErrorMessage(detailsError),
|
||||
});
|
||||
}
|
||||
|
||||
// PA05-21 requires runTime parameter in YYYYMMDD format (8 digits, date only)
|
||||
// If not provided, default to 1st of next month
|
||||
let runTime = options.scheduledAt || undefined;
|
||||
if (!runTime) {
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
nextMonth.setDate(1);
|
||||
const year = nextMonth.getFullYear();
|
||||
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const day = "01";
|
||||
runTime = `${year}${month}${day}`;
|
||||
this.logger.log(`No scheduledAt provided, defaulting to 1st of next month: ${runTime}`, {
|
||||
account,
|
||||
runTime,
|
||||
});
|
||||
}
|
||||
|
||||
const request: Omit<FreebitPlanChangeRequest, "authKey"> = {
|
||||
account,
|
||||
planCode: newPlanCode, // Use camelCase as required by Freebit API
|
||||
runTime: runTime, // Always include runTime for PA05-21
|
||||
// Only include globalip flag when explicitly requested
|
||||
...(options.assignGlobalIp === true ? { globalip: "1" } : {}),
|
||||
};
|
||||
|
||||
this.logger.log(`Attempting to change SIM plan via PA05-21`, {
|
||||
account,
|
||||
currentPlanCode,
|
||||
newPlanCode,
|
||||
planCode: newPlanCode,
|
||||
globalip: request.globalip,
|
||||
runTime: request.runTime,
|
||||
scheduledAt: options.scheduledAt,
|
||||
});
|
||||
|
||||
const response = await this.client.makeAuthenticatedRequest<
|
||||
PlanChangeResponse,
|
||||
FreebitPlanChangeResponse,
|
||||
typeof request
|
||||
>("/mvno/changePlan/", request);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`,
|
||||
{
|
||||
account: parsed.account,
|
||||
newPlanCode: parsed.newPlanCode,
|
||||
assignGlobalIp: parsed.assignGlobalIp,
|
||||
scheduled: Boolean(parsed.scheduledAt),
|
||||
}
|
||||
);
|
||||
this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
|
||||
account,
|
||||
newPlanCode,
|
||||
assignGlobalIp: options.assignGlobalIp,
|
||||
scheduled: !!options.scheduledAt,
|
||||
response: {
|
||||
resultCode: response.resultCode,
|
||||
statusCode: response.status?.statusCode,
|
||||
message: response.status?.message,
|
||||
},
|
||||
});
|
||||
this.stampOperation(account, "plan");
|
||||
|
||||
return {
|
||||
ipv4: response.ipv4,
|
||||
@ -267,17 +384,48 @@ export class FreebitOperationsService {
|
||||
};
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to change SIM plan for account ${account}`, {
|
||||
|
||||
// Extract Freebit error details if available
|
||||
const errorDetails: Record<string, unknown> = {
|
||||
account,
|
||||
newPlanCode,
|
||||
planCode: newPlanCode, // Use camelCase
|
||||
globalip: options.assignGlobalIp ? "1" : undefined,
|
||||
runTime: options.scheduledAt,
|
||||
error: message,
|
||||
});
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorDetails.errorName = error.name;
|
||||
errorDetails.errorMessage = error.message;
|
||||
|
||||
// Check if it's a FreebitError with additional properties
|
||||
if ('resultCode' in error) {
|
||||
errorDetails.resultCode = error.resultCode;
|
||||
}
|
||||
if ('statusCode' in error) {
|
||||
errorDetails.statusCode = error.statusCode;
|
||||
}
|
||||
if ('statusMessage' in error) {
|
||||
errorDetails.statusMessage = error.statusMessage;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`Failed to change SIM plan for account ${account}`, errorDetails);
|
||||
throw new BadRequestException(`Failed to change SIM plan: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SIM features (voice options and network type)
|
||||
*
|
||||
* IMPORTANT TIMING CONSTRAINTS from Freebit API:
|
||||
* - PA05-06 (voice features): Runs with immediate effect
|
||||
* - PA05-38 (contract line): Runs with immediate effect
|
||||
* - PA05-21 (plan change): Requires runTime parameter, scheduled for 1st of following month
|
||||
* - These must run 30 minutes apart to avoid canceling each other
|
||||
* - PA05-06 and PA05-38 should be done first, then PA05-21 last (since it's scheduled)
|
||||
* - PA05-21 and PA02-04 (cancellation) cannot coexist
|
||||
*/
|
||||
async updateSimFeatures(
|
||||
account: string,
|
||||
@ -289,45 +437,76 @@ export class FreebitOperationsService {
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Import and validate with the new schema
|
||||
const parsed = FreebitProvider.schemas.simFeatures.parse({
|
||||
account,
|
||||
const voiceFeatures = {
|
||||
voiceMailEnabled: features.voiceMailEnabled,
|
||||
callWaitingEnabled: features.callWaitingEnabled,
|
||||
callForwardingEnabled: undefined, // Not supported in this interface yet
|
||||
callerIdEnabled: undefined,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
account: parsed.account,
|
||||
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||
};
|
||||
|
||||
if (typeof parsed.voiceMailEnabled === "boolean") {
|
||||
const flag = parsed.voiceMailEnabled ? "10" : "20";
|
||||
payload.voiceMail = flag;
|
||||
payload.voicemail = flag;
|
||||
}
|
||||
const hasVoiceFeatures = Object.values(voiceFeatures).some(value => typeof value === "boolean");
|
||||
const hasNetworkTypeChange = typeof features.networkType === "string";
|
||||
|
||||
if (typeof parsed.callWaitingEnabled === "boolean") {
|
||||
const flag = parsed.callWaitingEnabled ? "10" : "20";
|
||||
payload.callWaiting = flag;
|
||||
payload.callwaiting = flag;
|
||||
}
|
||||
// Execute in sequence with 30-minute delays as per Freebit API requirements
|
||||
if (hasVoiceFeatures && hasNetworkTypeChange) {
|
||||
// Both voice features and network type change requested
|
||||
this.logger.log(`Updating both voice features and network type with required 30-minute delay`, {
|
||||
account,
|
||||
hasVoiceFeatures,
|
||||
hasNetworkTypeChange,
|
||||
});
|
||||
|
||||
if (typeof features.internationalRoamingEnabled === "boolean") {
|
||||
const flag = features.internationalRoamingEnabled ? "10" : "20";
|
||||
payload.worldWing = flag;
|
||||
payload.worldwing = flag;
|
||||
}
|
||||
// Step 1: Update voice features immediately (PA05-06)
|
||||
await this.updateVoiceFeatures(account, voiceFeatures);
|
||||
this.logger.log(`Voice features updated, scheduling network type change in 30 minutes`, {
|
||||
account,
|
||||
networkType: features.networkType,
|
||||
});
|
||||
|
||||
if (features.networkType) {
|
||||
payload.contractLine = features.networkType;
|
||||
}
|
||||
// Step 2: Schedule network type change 30 minutes later (PA05-38)
|
||||
// Note: This uses setTimeout which is not ideal for production
|
||||
// Consider using a job queue like Bull or agenda for production
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.updateNetworkType(account, features.networkType!);
|
||||
this.logger.log(`Network type change completed after 30-minute delay`, {
|
||||
account,
|
||||
networkType: features.networkType,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update network type after 30-minute delay`, {
|
||||
account,
|
||||
networkType: features.networkType,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}, 30 * 60 * 1000); // 30 minutes
|
||||
|
||||
await this.client.makeAuthenticatedRequest<AddSpecResponse, typeof payload>(
|
||||
"/master/addSpec/",
|
||||
payload
|
||||
);
|
||||
this.logger.log(`Voice features updated immediately, network type scheduled for 30 minutes`, {
|
||||
account,
|
||||
voiceMailEnabled: features.voiceMailEnabled,
|
||||
callWaitingEnabled: features.callWaitingEnabled,
|
||||
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||
networkType: features.networkType,
|
||||
});
|
||||
|
||||
} else if (hasVoiceFeatures) {
|
||||
// Only voice features (PA05-06)
|
||||
await this.updateVoiceFeatures(account, voiceFeatures);
|
||||
this.logger.log(`Voice features updated successfully`, {
|
||||
account,
|
||||
voiceMailEnabled: features.voiceMailEnabled,
|
||||
callWaitingEnabled: features.callWaitingEnabled,
|
||||
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||
});
|
||||
|
||||
} else if (hasNetworkTypeChange) {
|
||||
// Only network type change (PA05-38)
|
||||
await this.updateNetworkType(account, features.networkType!);
|
||||
this.logger.log(`Network type updated successfully`, {
|
||||
account,
|
||||
networkType: features.networkType,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`Successfully updated SIM features for account ${account}`, {
|
||||
account,
|
||||
@ -342,27 +521,221 @@ export class FreebitOperationsService {
|
||||
account,
|
||||
features,
|
||||
error: message,
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw new BadRequestException(`Failed to update SIM features: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update voice features (voicemail, call waiting, international roaming)
|
||||
* Uses PA05-06 MVNO Voice Option Change endpoint - runs with immediate effect
|
||||
*
|
||||
* Error codes specific to PA05-06:
|
||||
* - 243: Voice option (list) problem
|
||||
* - 244: Voicemail parameter problem
|
||||
* - 245: Call waiting parameter problem
|
||||
* - 250: WORLD WING parameter problem
|
||||
*/
|
||||
private async updateVoiceFeatures(
|
||||
account: string,
|
||||
features: {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.assertOperationSpacing(account, "voice");
|
||||
|
||||
const buildVoiceOptionPayload = (): Omit<FreebitVoiceOptionRequest, "authKey"> => {
|
||||
const talkOption: FreebitVoiceOptionSettings = {};
|
||||
|
||||
if (typeof features.voiceMailEnabled === "boolean") {
|
||||
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
|
||||
}
|
||||
|
||||
if (typeof features.callWaitingEnabled === "boolean") {
|
||||
talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
|
||||
}
|
||||
|
||||
if (typeof features.internationalRoamingEnabled === "boolean") {
|
||||
talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
|
||||
if (features.internationalRoamingEnabled) {
|
||||
talkOption.worldWingCreditLimit = "50000"; // minimum permitted when enabling
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(talkOption).length === 0) {
|
||||
throw new BadRequestException("No voice options specified for update");
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
userConfirmed: "10",
|
||||
aladinOperated: "10",
|
||||
talkOption,
|
||||
};
|
||||
};
|
||||
|
||||
const voiceOptionPayload = buildVoiceOptionPayload();
|
||||
|
||||
this.logger.debug("Submitting voice option change via /mvno/talkoption/changeOrder/ (PA05-06)", {
|
||||
account,
|
||||
payload: voiceOptionPayload,
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<
|
||||
FreebitVoiceOptionResponse,
|
||||
typeof voiceOptionPayload
|
||||
>("/mvno/talkoption/changeOrder/", voiceOptionPayload);
|
||||
|
||||
this.logger.log("Voice option change completed via PA05-06", {
|
||||
account,
|
||||
voiceMailEnabled: features.voiceMailEnabled,
|
||||
callWaitingEnabled: features.callWaitingEnabled,
|
||||
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||
});
|
||||
this.stampOperation(account, "voice");
|
||||
|
||||
// Save to database for future retrieval
|
||||
if (this.voiceOptionsService) {
|
||||
try {
|
||||
await this.voiceOptionsService.saveVoiceOptions(account, features);
|
||||
} catch (dbError) {
|
||||
this.logger.warn("Failed to save voice options to database (non-fatal)", {
|
||||
account,
|
||||
error: getErrorMessage(dbError),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to update voice features for account ${account}`, {
|
||||
account,
|
||||
features,
|
||||
error: message,
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw new BadRequestException(`Failed to update voice features: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update network type (4G/5G)
|
||||
* Uses PA05-38 contract line change - runs with immediate effect
|
||||
* NOTE: Must be called 30 minutes after PA05-06 if both are being updated
|
||||
*/
|
||||
private async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise<void> {
|
||||
try {
|
||||
this.assertOperationSpacing(account, "network");
|
||||
let eid: string | undefined;
|
||||
let productNumber: string | undefined;
|
||||
try {
|
||||
const details = await this.getSimDetails(account);
|
||||
if (details.eid) {
|
||||
eid = details.eid;
|
||||
} else if (details.iccid) {
|
||||
productNumber = details.iccid;
|
||||
}
|
||||
this.logger.debug(`Resolved SIM identifiers for contract line change`, {
|
||||
account,
|
||||
eid,
|
||||
productNumber,
|
||||
currentNetworkType: details.networkType,
|
||||
});
|
||||
if (details.networkType?.toUpperCase() === networkType.toUpperCase()) {
|
||||
this.logger.log(`Network type already ${networkType} for account ${account}; skipping update.`, {
|
||||
account,
|
||||
networkType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (resolveError) {
|
||||
this.logger.warn(`Unable to resolve SIM identifiers before contract line change`, {
|
||||
account,
|
||||
error: getErrorMessage(resolveError),
|
||||
});
|
||||
}
|
||||
|
||||
const request: Omit<FreebitContractLineChangeRequest, "authKey"> = {
|
||||
account,
|
||||
contractLine: networkType,
|
||||
...(eid ? { eid } : {}),
|
||||
...(productNumber ? { productNumber } : {}),
|
||||
};
|
||||
|
||||
this.logger.debug(`Updating network type via PA05-38 for account ${account}`, {
|
||||
account,
|
||||
networkType,
|
||||
request,
|
||||
});
|
||||
|
||||
const response = await this.client.makeAuthenticatedJsonRequest<
|
||||
FreebitContractLineChangeResponse,
|
||||
typeof request
|
||||
>("/mvno/contractline/change/", request);
|
||||
|
||||
this.logger.log(`Successfully updated network type for account ${account}`, {
|
||||
account,
|
||||
networkType,
|
||||
resultCode: response.resultCode,
|
||||
statusCode: response.status?.statusCode,
|
||||
message: response.status?.message,
|
||||
});
|
||||
this.stampOperation(account, "network");
|
||||
|
||||
// Save to database for future retrieval
|
||||
if (this.voiceOptionsService) {
|
||||
try {
|
||||
await this.voiceOptionsService.saveVoiceOptions(account, { networkType });
|
||||
} catch (dbError) {
|
||||
this.logger.warn("Failed to save network type to database (non-fatal)", {
|
||||
account,
|
||||
error: getErrorMessage(dbError),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to update network type for account ${account}`, {
|
||||
account,
|
||||
networkType,
|
||||
error: message,
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw new BadRequestException(`Failed to update network type: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel SIM service
|
||||
* Uses PA02-04 cancellation endpoint
|
||||
*
|
||||
* IMPORTANT CONSTRAINTS:
|
||||
* - Must be sent with runDate as 1st of client's cancellation month n+1
|
||||
* (e.g., cancel end of Jan = runDate 20250201)
|
||||
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
|
||||
* - PA05-21 and PA02-04 cannot coexist
|
||||
* - Must prevent clients from making further changes after cancellation is requested
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
try {
|
||||
const parsed: FreebitCancelPlanRequest = FreebitProvider.schemas.cancelPlan.parse({
|
||||
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
||||
account,
|
||||
runDate: scheduledAt,
|
||||
});
|
||||
|
||||
const request = {
|
||||
account: parsed.account,
|
||||
runTime: parsed.runDate,
|
||||
runTime: scheduledAt,
|
||||
};
|
||||
|
||||
await this.client.makeAuthenticatedRequest<CancelPlanResponse, typeof request>(
|
||||
this.logger.log(`Cancelling SIM service via PA02-04 for account ${account}`, {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
||||
"/mvno/releasePlan/",
|
||||
request
|
||||
);
|
||||
@ -371,6 +744,7 @@ export class FreebitOperationsService {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
});
|
||||
this.stampOperation(account, "cancellation");
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
||||
@ -387,11 +761,11 @@ export class FreebitOperationsService {
|
||||
*/
|
||||
async reissueEsimProfile(account: string): Promise<void> {
|
||||
try {
|
||||
const request = {
|
||||
const request: Omit<FreebitEsimReissueRequest, "authKey"> = {
|
||||
requestDatas: [{ kind: "MVNO", account }],
|
||||
};
|
||||
|
||||
await this.client.makeAuthenticatedRequest<EsimReissueResponse, typeof request>(
|
||||
await this.client.makeAuthenticatedRequest<FreebitEsimReissueResponse, typeof request>(
|
||||
"/mvno/reissueEsim/",
|
||||
request
|
||||
);
|
||||
@ -416,41 +790,25 @@ export class FreebitOperationsService {
|
||||
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const parsed: FreebitEsimReissueRequest = FreebitProvider.schemas.esimReissue.parse({
|
||||
account,
|
||||
newEid,
|
||||
oldEid: options.oldEid,
|
||||
planCode: options.planCode,
|
||||
oldProductNumber: options.oldProductNumber,
|
||||
});
|
||||
|
||||
const requestPayload = FreebitProvider.schemas.esimAddAccount.parse({
|
||||
const request: Omit<FreebitEsimAddAccountRequest, "authKey"> = {
|
||||
aladinOperated: "20",
|
||||
account: parsed.account,
|
||||
eid: parsed.newEid,
|
||||
account,
|
||||
eid: newEid,
|
||||
addKind: "R",
|
||||
planCode: parsed.planCode,
|
||||
});
|
||||
|
||||
const payload: FreebitEsimAddAccountRequest = {
|
||||
...requestPayload,
|
||||
authKey: await this.auth.getAuthKey(),
|
||||
planCode: options.planCode,
|
||||
};
|
||||
|
||||
await this.client.makeAuthenticatedRequest<
|
||||
EsimAddAccountResponse,
|
||||
FreebitEsimAddAccountRequest
|
||||
>("/mvno/esim/addAcnt/", payload);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully reissued eSIM profile via addAcnt for account ${parsed.account}`,
|
||||
{
|
||||
account: parsed.account,
|
||||
newEid: parsed.newEid,
|
||||
oldProductNumber: parsed.oldProductNumber,
|
||||
oldEid: parsed.oldEid,
|
||||
}
|
||||
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
|
||||
"/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) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
||||
@ -472,7 +830,12 @@ export class FreebitOperationsService {
|
||||
contractLine?: "4G" | "5G";
|
||||
aladinOperated?: "10" | "20";
|
||||
shipDate?: string;
|
||||
mnp?: { reserveNumber: string; reserveExpireDate: string };
|
||||
addKind?: "N" | "M" | "R"; // N:新規, M:MNP転入, R:再発行
|
||||
simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり (Required except when addKind='R')
|
||||
repAccount?: string; // 代表番号
|
||||
deliveryCode?: string; // 顧客コード
|
||||
globalIp?: "10" | "20"; // 10:なし, 20:あり
|
||||
mnp?: { reserveNumber: string; reserveExpireDate?: string };
|
||||
identity?: {
|
||||
firstnameKanji?: string;
|
||||
lastnameKanji?: string;
|
||||
@ -489,55 +852,58 @@ export class FreebitOperationsService {
|
||||
contractLine,
|
||||
aladinOperated = "10",
|
||||
shipDate,
|
||||
addKind,
|
||||
simKind,
|
||||
repAccount,
|
||||
deliveryCode,
|
||||
globalIp,
|
||||
mnp,
|
||||
identity,
|
||||
} = params;
|
||||
|
||||
// Import schemas dynamically to avoid circular dependencies
|
||||
const validatedParams: FreebitEsimActivationParams =
|
||||
FreebitProvider.schemas.esimActivationParams.parse({
|
||||
account,
|
||||
eid,
|
||||
planCode,
|
||||
contractLine,
|
||||
aladinOperated,
|
||||
shipDate,
|
||||
mnp,
|
||||
identity,
|
||||
});
|
||||
|
||||
if (!validatedParams.account || !validatedParams.eid) {
|
||||
if (!account || !eid) {
|
||||
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: FreebitEsimActivationRequest = {
|
||||
authKey: await this.auth.getAuthKey(),
|
||||
aladinOperated: validatedParams.aladinOperated,
|
||||
createType: "new",
|
||||
account: validatedParams.account,
|
||||
eid: validatedParams.eid,
|
||||
simkind: "esim",
|
||||
planCode: validatedParams.planCode,
|
||||
contractLine: validatedParams.contractLine,
|
||||
shipDate: validatedParams.shipDate,
|
||||
...(validatedParams.mnp ? { mnp: validatedParams.mnp } : {}),
|
||||
...(validatedParams.identity ? validatedParams.identity : {}),
|
||||
};
|
||||
const finalAddKind = addKind || "N";
|
||||
|
||||
// Validate the full API request payload
|
||||
FreebitProvider.schemas.esimActivationRequest.parse(payload);
|
||||
// Validate simKind: Required except when addKind is 'R' (reissue)
|
||||
if (finalAddKind !== "R" && !simKind) {
|
||||
throw new BadRequestException(
|
||||
"simKind is required for eSIM activation (use 'E0' for voice, 'E3' for SMS, 'E2' for data-only)"
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: FreebitEsimAccountActivationRequest = {
|
||||
authKey: await this.auth.getAuthKey(),
|
||||
aladinOperated,
|
||||
createType: "new",
|
||||
eid,
|
||||
account,
|
||||
simkind: simKind || "E0", // Default to voice-enabled if not specified
|
||||
addKind: finalAddKind,
|
||||
planCode,
|
||||
contractLine,
|
||||
shipDate,
|
||||
repAccount,
|
||||
deliveryCode,
|
||||
globalIp,
|
||||
...(mnp ? { mnp } : {}),
|
||||
...(identity ? identity : {}),
|
||||
} as FreebitEsimAccountActivationRequest;
|
||||
|
||||
// Use JSON request for PA05-41
|
||||
await this.client.makeAuthenticatedJsonRequest<EsimActivationResponse, typeof payload>(
|
||||
"/mvno/esim/addAcct/",
|
||||
payload
|
||||
);
|
||||
await this.client.makeAuthenticatedJsonRequest<
|
||||
FreebitEsimAccountActivationResponse,
|
||||
FreebitEsimAccountActivationRequest
|
||||
>("/mvno/esim/addAcct/", payload);
|
||||
|
||||
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
||||
account,
|
||||
planCode,
|
||||
contractLine,
|
||||
addKind: addKind || "N",
|
||||
scheduled: !!shipDate,
|
||||
mnp: !!mnp,
|
||||
});
|
||||
@ -547,6 +913,7 @@ export class FreebitOperationsService {
|
||||
account,
|
||||
eid,
|
||||
planCode,
|
||||
addKind,
|
||||
error: message,
|
||||
});
|
||||
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { FreebitOperationsService } from "./freebit-operations.service";
|
||||
import { Freebit } from "@customer-portal/domain/sim/providers/freebit";
|
||||
import type { SimDetails, SimUsage, SimTopUpHistory } from "@customer-portal/domain/sim";
|
||||
import { FreebitMapperService } from "./freebit-mapper.service";
|
||||
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types";
|
||||
|
||||
@Injectable()
|
||||
export class FreebitOrchestratorService {
|
||||
constructor(private readonly operations: FreebitOperationsService) {}
|
||||
constructor(
|
||||
private readonly operations: FreebitOperationsService,
|
||||
private readonly mapper: FreebitMapperService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get SIM account details
|
||||
*/
|
||||
async getSimDetails(account: string): Promise<SimDetails> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.getSimDetails(normalizedAccount);
|
||||
}
|
||||
|
||||
@ -19,7 +22,7 @@ export class FreebitOrchestratorService {
|
||||
* Get SIM usage information
|
||||
*/
|
||||
async getSimUsage(account: string): Promise<SimUsage> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.getSimUsage(normalizedAccount);
|
||||
}
|
||||
|
||||
@ -31,7 +34,7 @@ export class FreebitOrchestratorService {
|
||||
quotaMb: number,
|
||||
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
||||
): Promise<void> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.topUpSim(normalizedAccount, quotaMb, options);
|
||||
}
|
||||
|
||||
@ -43,7 +46,7 @@ export class FreebitOrchestratorService {
|
||||
fromDate: string,
|
||||
toDate: string
|
||||
): Promise<SimTopUpHistory> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.getSimTopUpHistory(normalizedAccount, fromDate, toDate);
|
||||
}
|
||||
|
||||
@ -55,7 +58,7 @@ export class FreebitOrchestratorService {
|
||||
newPlanCode: string,
|
||||
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.changeSimPlan(normalizedAccount, newPlanCode, options);
|
||||
}
|
||||
|
||||
@ -71,7 +74,7 @@ export class FreebitOrchestratorService {
|
||||
networkType?: "4G" | "5G";
|
||||
}
|
||||
): Promise<void> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.updateSimFeatures(normalizedAccount, features);
|
||||
}
|
||||
|
||||
@ -79,7 +82,7 @@ export class FreebitOrchestratorService {
|
||||
* Cancel SIM service
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.cancelSim(normalizedAccount, scheduledAt);
|
||||
}
|
||||
|
||||
@ -87,7 +90,7 @@ export class FreebitOrchestratorService {
|
||||
* Reissue eSIM profile (simple)
|
||||
*/
|
||||
async reissueEsimProfile(account: string): Promise<void> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.reissueEsimProfile(normalizedAccount);
|
||||
}
|
||||
|
||||
@ -99,7 +102,7 @@ export class FreebitOrchestratorService {
|
||||
newEid: string,
|
||||
options: { oldEid?: string; planCode?: string } = {}
|
||||
): Promise<void> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||
return this.operations.reissueEsimProfileEnhanced(normalizedAccount, newEid, options);
|
||||
}
|
||||
|
||||
@ -113,7 +116,12 @@ export class FreebitOrchestratorService {
|
||||
contractLine?: "4G" | "5G";
|
||||
aladinOperated?: "10" | "20";
|
||||
shipDate?: string;
|
||||
mnp?: { reserveNumber: string; reserveExpireDate: string };
|
||||
addKind?: "N" | "M" | "R";
|
||||
simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり
|
||||
repAccount?: string;
|
||||
deliveryCode?: string;
|
||||
globalIp?: "10" | "20";
|
||||
mnp?: { reserveNumber: string; reserveExpireDate?: string };
|
||||
identity?: {
|
||||
firstnameKanji?: string;
|
||||
lastnameKanji?: string;
|
||||
@ -123,7 +131,7 @@ export class FreebitOrchestratorService {
|
||||
birthday?: string;
|
||||
};
|
||||
}): Promise<void> {
|
||||
const normalizedAccount = Freebit.normalizeAccount(params.account);
|
||||
const normalizedAccount = this.mapper.normalizeAccount(params.account);
|
||||
return this.operations.activateEsimAccountNew({
|
||||
account: normalizedAccount,
|
||||
eid: params.eid,
|
||||
@ -131,6 +139,11 @@ export class FreebitOrchestratorService {
|
||||
contractLine: params.contractLine,
|
||||
aladinOperated: params.aladinOperated,
|
||||
shipDate: params.shipDate,
|
||||
addKind: params.addKind,
|
||||
simKind: params.simKind,
|
||||
repAccount: params.repAccount,
|
||||
deliveryCode: params.deliveryCode,
|
||||
globalIp: params.globalIp,
|
||||
mnp: params.mnp,
|
||||
identity: params.identity,
|
||||
});
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
// Export all Freebit services
|
||||
export { FreebitOrchestratorService } from "./freebit-orchestrator.service";
|
||||
export { FreebitMapperService } from "./freebit-mapper.service";
|
||||
export { FreebitOperationsService } from "./freebit-operations.service";
|
||||
export { FreebitClientService } from "./freebit-client.service";
|
||||
export { FreebitAuthService } from "./freebit-auth.service";
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service";
|
||||
import { SimNotificationService } from "./sim-management/services/sim-notification.service";
|
||||
import type { SimDetails, SimInfo, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
|
||||
import type {
|
||||
SimDetails,
|
||||
SimUsage,
|
||||
SimTopUpHistory,
|
||||
} from "@bff/integrations/freebit/interfaces/freebit.types";
|
||||
import type {
|
||||
SimTopUpRequest,
|
||||
SimPlanChangeRequest,
|
||||
SimCancelRequest,
|
||||
SimTopUpHistoryRequest,
|
||||
SimFeaturesUpdateRequest,
|
||||
SimReissueRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
} from "./sim-management/types/sim-requests.types";
|
||||
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
|
||||
|
||||
@Injectable()
|
||||
@ -38,6 +41,13 @@ export class SimManagementService {
|
||||
return this.simOrchestrator.debugSimSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to query Freebit directly for any account's details
|
||||
*/
|
||||
async getSimDetailsDebug(account: string): Promise<SimDetails> {
|
||||
return this.simOrchestrator.getSimDetailsDirectly(account);
|
||||
}
|
||||
|
||||
// This method is now handled by SimValidationService internally
|
||||
|
||||
/**
|
||||
@ -69,6 +79,7 @@ export class SimManagementService {
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimTopUpHistoryRequest
|
||||
// @ts-ignore - ignoring mismatch for now as we are migrating
|
||||
): Promise<SimTopUpHistory> {
|
||||
return this.simOrchestrator.getSimTopUpHistory(userId, subscriptionId, request);
|
||||
}
|
||||
@ -109,18 +120,20 @@ export class SimManagementService {
|
||||
/**
|
||||
* Reissue eSIM profile
|
||||
*/
|
||||
async reissueEsimProfile(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimReissueRequest
|
||||
): Promise<void> {
|
||||
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request);
|
||||
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
||||
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive SIM information (details + usage combined)
|
||||
*/
|
||||
async getSimInfo(userId: string, subscriptionId: number): Promise<SimInfo> {
|
||||
async getSimInfo(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<{
|
||||
details: SimDetails;
|
||||
usage: SimUsage;
|
||||
}> {
|
||||
return this.simOrchestrator.getSimInfo(userId, subscriptionId);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
export interface VoiceOptionsSettings {
|
||||
voiceMailEnabled: boolean;
|
||||
callWaitingEnabled: boolean;
|
||||
internationalRoamingEnabled: boolean;
|
||||
networkType: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SimVoiceOptionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get voice options for a SIM account
|
||||
* Returns null if no settings found
|
||||
*/
|
||||
async getVoiceOptions(account: string): Promise<VoiceOptionsSettings | null> {
|
||||
try {
|
||||
const options = await this.prisma.simVoiceOptions.findUnique({
|
||||
where: { account },
|
||||
});
|
||||
|
||||
if (!options) {
|
||||
this.logger.debug(`No voice options found in database for account ${account}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
voiceMailEnabled: options.voiceMailEnabled,
|
||||
callWaitingEnabled: options.callWaitingEnabled,
|
||||
internationalRoamingEnabled: options.internationalRoamingEnabled,
|
||||
networkType: options.networkType,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get voice options for account ${account}`, { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save or update voice options for a SIM account
|
||||
*/
|
||||
async saveVoiceOptions(account: string, settings: Partial<VoiceOptionsSettings>): Promise<void> {
|
||||
try {
|
||||
await this.prisma.simVoiceOptions.upsert({
|
||||
where: { account },
|
||||
create: {
|
||||
account,
|
||||
voiceMailEnabled: settings.voiceMailEnabled ?? false,
|
||||
callWaitingEnabled: settings.callWaitingEnabled ?? false,
|
||||
internationalRoamingEnabled: settings.internationalRoamingEnabled ?? false,
|
||||
networkType: settings.networkType ?? "4G",
|
||||
},
|
||||
update: {
|
||||
...(settings.voiceMailEnabled !== undefined && {
|
||||
voiceMailEnabled: settings.voiceMailEnabled,
|
||||
}),
|
||||
...(settings.callWaitingEnabled !== undefined && {
|
||||
callWaitingEnabled: settings.callWaitingEnabled,
|
||||
}),
|
||||
...(settings.internationalRoamingEnabled !== undefined && {
|
||||
internationalRoamingEnabled: settings.internationalRoamingEnabled,
|
||||
}),
|
||||
...(settings.networkType !== undefined && {
|
||||
networkType: settings.networkType,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Saved voice options for account ${account}`, { settings });
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to save voice options for account ${account}`, {
|
||||
error,
|
||||
settings,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize voice options for a new SIM account
|
||||
*/
|
||||
async initializeVoiceOptions(
|
||||
account: string,
|
||||
settings: {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: string;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
await this.saveVoiceOptions(account, {
|
||||
voiceMailEnabled: settings.voiceMailEnabled ?? true,
|
||||
callWaitingEnabled: settings.callWaitingEnabled ?? true,
|
||||
internationalRoamingEnabled: settings.internationalRoamingEnabled ?? true,
|
||||
networkType: settings.networkType ?? "5G",
|
||||
});
|
||||
|
||||
this.logger.log(`Initialized voice options for new SIM account ${account}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete voice options for a SIM account (e.g., when SIM is cancelled)
|
||||
*/
|
||||
async deleteVoiceOptions(account: string): Promise<void> {
|
||||
try {
|
||||
await this.prisma.simVoiceOptions.delete({
|
||||
where: { account },
|
||||
});
|
||||
|
||||
this.logger.log(`Deleted voice options for account ${account}`);
|
||||
} catch (error) {
|
||||
// Silently ignore if record doesn't exist
|
||||
this.logger.debug(`Could not delete voice options for account ${account}`, { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { FreebitModule } from "@bff/integrations/freebit/freebit.module";
|
||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
|
||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module";
|
||||
@ -23,9 +23,16 @@ import { SimScheduleService } from "./services/sim-schedule.service";
|
||||
import { SimActionRunnerService } from "./services/sim-action-runner.service";
|
||||
import { SimManagementQueueService } from "./queue/sim-management.queue";
|
||||
import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||
|
||||
@Module({
|
||||
imports: [FreebitModule, WhmcsModule, SalesforceModule, MappingsModule, EmailModule],
|
||||
imports: [
|
||||
forwardRef(() => FreebitModule),
|
||||
WhmcsModule,
|
||||
SalesforceModule,
|
||||
MappingsModule,
|
||||
EmailModule,
|
||||
],
|
||||
providers: [
|
||||
// Core services that the SIM services depend on
|
||||
SimUsageStoreService,
|
||||
@ -34,6 +41,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||
// SIM management services
|
||||
SimValidationService,
|
||||
SimNotificationService,
|
||||
SimVoiceOptionsService,
|
||||
SimDetailsService,
|
||||
SimUsageService,
|
||||
SimTopUpService,
|
||||
@ -47,6 +55,11 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||
SimActionRunnerService,
|
||||
SimManagementQueueService,
|
||||
SimManagementProcessor,
|
||||
// Export with token for optional injection in Freebit module
|
||||
{
|
||||
provide: "SimVoiceOptionsService",
|
||||
useExisting: SimVoiceOptionsService,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
SimOrchestratorService,
|
||||
@ -64,6 +77,8 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||
SimScheduleService,
|
||||
SimActionRunnerService,
|
||||
SimManagementQueueService,
|
||||
SimVoiceOptionsService,
|
||||
"SimVoiceOptionsService", // Export the token
|
||||
],
|
||||
})
|
||||
export class SimManagementModule {}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
export interface SimTopUpRequest {
|
||||
quotaMb: number;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface SimPlanChangeRequest {
|
||||
newPlanCode: "5GB" | "10GB" | "25GB" | "50GB";
|
||||
effectiveDate?: string;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@ -32,13 +32,10 @@ import {
|
||||
simChangePlanRequestSchema,
|
||||
simCancelRequestSchema,
|
||||
simFeaturesRequestSchema,
|
||||
simReissueRequestSchema,
|
||||
type SimInfo,
|
||||
type SimTopupRequest,
|
||||
type SimChangePlanRequest,
|
||||
type SimCancelRequest,
|
||||
type SimFeaturesRequest,
|
||||
type SimReissueRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
@ -120,6 +117,11 @@ export class SubscriptionsController {
|
||||
return { success: true, data: preview };
|
||||
}
|
||||
|
||||
@Get("debug/sim-details/:account")
|
||||
async debugSimDetails(@Param("account") account: string) {
|
||||
return await this.simManagementService.getSimDetailsDebug(account);
|
||||
}
|
||||
|
||||
@Get(":id/sim/debug")
|
||||
async debugSimSubscription(
|
||||
@Request() req: RequestWithUser,
|
||||
@ -132,7 +134,7 @@ export class SubscriptionsController {
|
||||
async getSimInfo(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
): Promise<SimInfo> {
|
||||
) {
|
||||
return this.simManagementService.getSimInfo(req.user.id, subscriptionId);
|
||||
}
|
||||
|
||||
@ -207,13 +209,12 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Post(":id/sim/reissue-esim")
|
||||
@UsePipes(new ZodValidationPipe(simReissueRequestSchema))
|
||||
async reissueEsimProfile(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: SimReissueRequest
|
||||
@Body() body: { newEid?: string } = {}
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body);
|
||||
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid);
|
||||
return { success: true, message: "eSIM profile reissue completed successfully" };
|
||||
}
|
||||
|
||||
|
||||
0
apps/portal/scripts/test-request-password-reset.cjs
Normal file → Executable file
0
apps/portal/scripts/test-request-password-reset.cjs
Normal file → Executable file
@ -3,11 +3,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
SIM_PLAN_OPTIONS,
|
||||
type SimPlanCode,
|
||||
getSimPlanLabel,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { mapToSimplifiedFormat } from "../utils/plan";
|
||||
|
||||
interface ChangePlanModalProps {
|
||||
subscriptionId: number;
|
||||
@ -24,9 +20,16 @@ export function ChangePlanModal({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: ChangePlanModalProps) {
|
||||
const allowedPlans = SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode);
|
||||
const PLAN_CODES = ["5GB", "10GB", "25GB", "50GB"] as const;
|
||||
type PlanCode = (typeof PLAN_CODES)[number];
|
||||
|
||||
const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>("");
|
||||
const normalizedCurrentPlan = mapToSimplifiedFormat(currentPlanCode);
|
||||
|
||||
const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(
|
||||
code => code !== (normalizedCurrentPlan as PlanCode)
|
||||
);
|
||||
|
||||
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
@ -78,13 +81,13 @@ export function ChangePlanModal({
|
||||
</label>
|
||||
<select
|
||||
value={newPlanCode}
|
||||
onChange={e => setNewPlanCode(e.target.value as SimPlanCode)}
|
||||
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(option => (
|
||||
<option key={option.code} value={option.code}>
|
||||
{getSimPlanLabel(option.code)}
|
||||
{allowedPlans.map(code => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@ -2,7 +2,18 @@
|
||||
|
||||
import React from "react";
|
||||
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import type { SimUsage } from "@customer-portal/domain/sim";
|
||||
|
||||
export interface SimUsage {
|
||||
account: string;
|
||||
todayUsageKb: number;
|
||||
todayUsageMb: number;
|
||||
recentDaysUsage: Array<{
|
||||
date: string;
|
||||
usageKb: number;
|
||||
usageMb: number;
|
||||
}>;
|
||||
isBlacklisted: boolean;
|
||||
}
|
||||
|
||||
interface DataUsageChartProps {
|
||||
usage: SimUsage;
|
||||
@ -210,19 +221,6 @@ export function DataUsageChart({
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
|
||||
@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
||||
|
||||
type SimKind = "physical" | "esim";
|
||||
|
||||
interface ReissueSimModalProps {
|
||||
subscriptionId: number;
|
||||
currentSimType: SimKind;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
const IMPORTANT_POINTS: string[] = [
|
||||
"The reissue request cannot be reversed.",
|
||||
"Service to the existing SIM will be terminated with immediate effect.",
|
||||
"A fee of 1,500 yen + tax will be incurred.",
|
||||
"For physical SIM: allow approximately 3-5 business days for shipping.",
|
||||
"For eSIM: activation typically completes within 30-60 minutes after processing.",
|
||||
];
|
||||
|
||||
const EID_HELP = "Enter the 32-digit EID (numbers only). Leave blank to reuse Freebit's generated EID.";
|
||||
|
||||
export function ReissueSimModal({
|
||||
subscriptionId,
|
||||
currentSimType,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: ReissueSimModalProps) {
|
||||
const [selectedSimType, setSelectedSimType] = useState<SimKind>(currentSimType);
|
||||
const [newEid, setNewEid] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const isEsimSelected = selectedSimType === "esim";
|
||||
const isPhysicalSelected = selectedSimType === "physical";
|
||||
|
||||
const disableSubmit = useMemo(() => {
|
||||
if (isPhysicalSelected) {
|
||||
return false; // Allow click to show guidance message
|
||||
}
|
||||
if (!isEsimSelected) {
|
||||
return true;
|
||||
}
|
||||
if (!newEid) {
|
||||
return false; // Optional – backend supports auto EID
|
||||
}
|
||||
return !/^\d{32}$/.test(newEid.trim());
|
||||
}, [isPhysicalSelected, isEsimSelected, newEid]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isPhysicalSelected) {
|
||||
setValidationError(
|
||||
"Physical SIM reissue cannot be requested online yet. Please contact support for assistance."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEsimSelected && newEid && !/^\d{32}$/.test(newEid.trim())) {
|
||||
setValidationError("EID must be 32 digits.");
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await simActionsService.reissueEsim(String(subscriptionId), {
|
||||
newEid: newEid.trim() || undefined,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Failed to submit reissue request";
|
||||
onError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-gray-500 bg-opacity-75" aria-hidden="true" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-2xl rounded-lg border border-gray-200 bg-white shadow-2xl">
|
||||
<div className="px-6 pt-6 pb-4 sm:px-8 sm:pb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
|
||||
<ArrowPathIcon className="h-6 w-6 text-green-600" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Reissue SIM</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Submit a reissue request for your SIM. Review the important information before continuing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 transition hover:text-gray-600"
|
||||
aria-label="Close reissue SIM modal"
|
||||
type="button"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<h4 className="text-sm font-semibold text-amber-800">Important information</h4>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-amber-900">
|
||||
{IMPORTANT_POINTS.map(point => (
|
||||
<li key={point}>{point}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Select SIM type</label>
|
||||
<div className="mt-3 space-y-2">
|
||||
<label className="flex items-start gap-3 rounded-lg border border-gray-200 p-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="sim-type"
|
||||
value="physical"
|
||||
checked={selectedSimType === "physical"}
|
||||
onChange={() => setSelectedSimType("physical")}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Physical SIM</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
We’ll ship a replacement SIM card. Currently, online requests are not available; contact support to proceed.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 rounded-lg border border-gray-200 p-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="sim-type"
|
||||
value="esim"
|
||||
checked={selectedSimType === "esim"}
|
||||
onChange={() => setSelectedSimType("esim")}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">eSIM</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Generate a new eSIM activation profile. You’ll receive new QR code details once processing completes.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-4 text-sm text-gray-600">
|
||||
<p>
|
||||
Current SIM type: <strong className="uppercase">{currentSimType}</strong>
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
The selection above lets you specify which type of replacement you need. If you choose a physical SIM, a support agent will contact you to finalise the process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEsimSelected && (
|
||||
<div className="mt-6">
|
||||
<label htmlFor="new-eid" className="block text-sm font-medium text-gray-700">
|
||||
New EID (optional)
|
||||
</label>
|
||||
<input
|
||||
id="new-eid"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={newEid}
|
||||
onChange={event => {
|
||||
setNewEid(event.target.value.replace(/\s+/g, ""));
|
||||
setValidationError(null);
|
||||
}}
|
||||
placeholder="Enter 32-digit EID"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">{EID_HELP}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationError && (
|
||||
<p className="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{validationError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-gray-200 bg-gray-50 p-4 sm:flex-row sm:justify-end sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={disableSubmit || submitting}
|
||||
className="inline-flex justify-center rounded-md px-4 py-2 text-sm font-semibold text-white shadow-sm disabled:cursor-not-allowed disabled:opacity-70"
|
||||
style={{ background: "linear-gradient(90deg, #16a34a, #15803d)" }}
|
||||
>
|
||||
{submitting ? "Submitting..." : isPhysicalSelected ? "Contact Support" : "Confirm Reissue"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
PlusIcon,
|
||||
@ -11,18 +11,13 @@ import {
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { TopUpModal } from "./TopUpModal";
|
||||
import { ChangePlanModal } from "./ChangePlanModal";
|
||||
import { ReissueSimModal } from "./ReissueSimModal";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
canTopUpSim,
|
||||
canReissueEsim,
|
||||
canCancelSim,
|
||||
type SimStatus,
|
||||
} from "@customer-portal/domain/sim";
|
||||
|
||||
interface SimActionsProps {
|
||||
subscriptionId: number;
|
||||
simType: "physical" | "esim";
|
||||
status: SimStatus;
|
||||
status: string;
|
||||
onTopUpSuccess?: () => void;
|
||||
onPlanChangeSuccess?: () => void;
|
||||
onCancelSuccess?: () => void;
|
||||
@ -45,7 +40,7 @@ export function SimActions({
|
||||
const router = useRouter();
|
||||
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||
const [showReissueConfirm, setShowReissueConfirm] = useState(false);
|
||||
const [showReissueModal, setShowReissueModal] = useState(false);
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
@ -54,29 +49,17 @@ export function SimActions({
|
||||
"topup" | "reissue" | "cancel" | "changePlan" | null
|
||||
>(null);
|
||||
|
||||
const isActiveStatus = canTopUpSim(status);
|
||||
const canTopUp = isActiveStatus;
|
||||
const canReissue = simType === "esim" && canReissueEsim(status);
|
||||
const canCancel = canCancelSim(status);
|
||||
const isActive = status === "active";
|
||||
const canTopUp = isActive;
|
||||
const canReissue = isActive;
|
||||
const canCancel = isActive;
|
||||
|
||||
const handleReissueEsim = async () => {
|
||||
setLoading("reissue");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.POST("/api/subscriptions/{id}/sim/reissue-esim", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
setSuccess("eSIM profile reissued successfully");
|
||||
setShowReissueConfirm(false);
|
||||
onReissueSuccess?.();
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "Failed to reissue eSIM profile");
|
||||
} finally {
|
||||
setLoading(null);
|
||||
const reissueDisabledReason = useMemo(() => {
|
||||
if (!isActive) {
|
||||
return "SIM must be active to request a reissue.";
|
||||
}
|
||||
};
|
||||
return null;
|
||||
}, [isActive]);
|
||||
|
||||
const handleCancelSim = async () => {
|
||||
setLoading("cancel");
|
||||
@ -85,6 +68,7 @@ export function SimActions({
|
||||
try {
|
||||
await apiClient.POST("/api/subscriptions/{id}/sim/cancel", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: {},
|
||||
});
|
||||
|
||||
setSuccess("SIM service cancelled successfully");
|
||||
@ -112,32 +96,17 @@ export function SimActions({
|
||||
return (
|
||||
<div
|
||||
id="sim-actions"
|
||||
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
|
||||
className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}
|
||||
>
|
||||
{/* 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>
|
||||
{!embedded && (
|
||||
<div className="px-6 py-6 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-slate-900 mb-1">
|
||||
SIM Management Actions
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600">Manage your SIM service</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||
@ -160,7 +129,7 @@ export function SimActions({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isActiveStatus && (
|
||||
{!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" />
|
||||
@ -172,7 +141,7 @@ export function SimActions({
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className={`grid gap-4 ${embedded ? "grid-cols-1" : "grid-cols-2"}`}>
|
||||
<div className="space-y-3">
|
||||
{/* Top Up Data - Primary Action */}
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -184,70 +153,20 @@ export function SimActions({
|
||||
}
|
||||
}}
|
||||
disabled={!canTopUp || loading !== null}
|
||||
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
canTopUp && loading === null
|
||||
? "text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
|
||||
? "text-white bg-blue-600 hover:bg-blue-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 active:scale-[0.98]"
|
||||
: "text-gray-400 bg-gray-100 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" />
|
||||
<PlusIcon className="h-4 w-4 mr-3" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{loading === "topup" ? "Processing..." : "Top Up Data"}
|
||||
</div>
|
||||
<span>{loading === "reissue" ? "Processing..." : "Reissue eSIM"}</span>
|
||||
<div className="text-xs opacity-90">Add more data to your plan</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel SIM - Destructive Action */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo("cancel");
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
} catch {
|
||||
// Fallback to inline confirmation modal if navigation is unavailable
|
||||
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>
|
||||
|
||||
@ -261,30 +180,81 @@ export function SimActions({
|
||||
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"
|
||||
disabled={!isActive || loading !== null}
|
||||
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
isActive && loading === null
|
||||
? "text-slate-700 bg-slate-100 hover:bg-slate-200 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 active:scale-[0.98]"
|
||||
: "text-gray-400 bg-gray-100 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>
|
||||
<ArrowPathIcon className="h-4 w-4 mr-3" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{loading === "change-plan" ? "Processing..." : "Change Plan"}
|
||||
</div>
|
||||
<div className="text-xs opacity-70">Switch to a different plan</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Reissue SIM */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo("reissue");
|
||||
setShowReissueModal(true);
|
||||
}}
|
||||
disabled={!canReissue || loading !== null}
|
||||
className={`w-full flex flex-col items-start justify-start rounded-lg border px-4 py-4 text-left text-sm font-medium transition-all duration-200 ${
|
||||
canReissue && loading === null
|
||||
? "border-green-200 bg-green-50 text-green-900 hover:bg-green-100 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 active:scale-[0.98]"
|
||||
: "text-gray-400 bg-gray-100 border-gray-200 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<ArrowPathIcon className="h-4 w-4 mr-3" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">{"Reissue SIM"}</div>
|
||||
<div className="text-xs opacity-70">
|
||||
Configure replacement options and submit your request.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!canReissue && reissueDisabledReason && (
|
||||
<div className="mt-3 w-full rounded-md border border-yellow-200 bg-yellow-50 px-3 py-2 text-xs text-yellow-800">
|
||||
{reissueDisabledReason}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Cancel SIM - Destructive Action */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveInfo("cancel");
|
||||
try {
|
||||
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||
} catch {
|
||||
// Fallback to inline confirmation modal if navigation is unavailable
|
||||
setShowCancelConfirm(true);
|
||||
}
|
||||
}}
|
||||
disabled={!canCancel || loading !== null}
|
||||
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
canCancel && loading === null
|
||||
? "text-red-700 bg-white border border-red-200 hover:bg-red-50 hover:border-red-300 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 active:scale-[0.98]"
|
||||
: "text-gray-400 bg-gray-100 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<XMarkIcon className="h-4 w-4 mr-3" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{loading === "cancel" ? "Processing..." : "Cancel SIM"}
|
||||
</div>
|
||||
<div className="text-xs opacity-70">Permanently cancel service</div>
|
||||
</div>
|
||||
<span>Change Plan</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@ -305,8 +275,7 @@ export function SimActions({
|
||||
<div className="flex items-start">
|
||||
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this
|
||||
if your previous download failed or you need to install on a new device.
|
||||
<strong>Reissue SIM:</strong> Submit a replacement request for either a physical SIM or an eSIM. eSIM users can optionally supply a new EID to pair with the replacement profile.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -382,54 +351,24 @@ export function SimActions({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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={() => void 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>
|
||||
{/* Reissue SIM Modal */}
|
||||
{showReissueModal && (
|
||||
<ReissueSimModal
|
||||
subscriptionId={subscriptionId}
|
||||
currentSimType={simType}
|
||||
onClose={() => {
|
||||
setShowReissueModal(false);
|
||||
setActiveInfo(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowReissueModal(false);
|
||||
setSuccess("SIM reissue request submitted successfully");
|
||||
onReissueSuccess?.();
|
||||
}}
|
||||
onError={message => {
|
||||
setError(message);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cancel Confirmation */}
|
||||
|
||||
@ -1,211 +1,430 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { formatSimPlanShort } from "@customer-portal/domain/sim";
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
WifiIcon,
|
||||
SignalIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
ClockIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { SimDetails, SimStatus } from "@customer-portal/domain/sim";
|
||||
|
||||
// Inline formatPlanShort function
|
||||
function formatPlanShort(planCode?: string): string {
|
||||
if (!planCode) return "—";
|
||||
const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
|
||||
if (m && m[1]) {
|
||||
return `${m[1]}G`;
|
||||
}
|
||||
// Try extracting trailing number+G anywhere in the string
|
||||
const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
|
||||
if (m2 && m2[1]) {
|
||||
return `${m2[1]}G`;
|
||||
}
|
||||
return planCode;
|
||||
}
|
||||
|
||||
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;
|
||||
showFeaturesSummary?: boolean;
|
||||
embedded?: boolean; // when true, render content without card container
|
||||
showFeaturesSummary?: boolean; // show the right-side Service Features summary
|
||||
}
|
||||
|
||||
const STATUS_ICON_MAP: Record<SimStatus, React.ReactNode> = {
|
||||
active: <CheckCircleIcon className="h-5 w-5 text-green-500" />,
|
||||
suspended: <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />,
|
||||
cancelled: <XCircleIcon className="h-5 w-5 text-red-500" />,
|
||||
pending: <ClockIcon className="h-5 w-5 text-blue-500" />,
|
||||
};
|
||||
|
||||
const STATUS_BADGE_CLASS_MAP: Record<SimStatus, string> = {
|
||||
active: "bg-green-100 text-green-800",
|
||||
suspended: "bg-yellow-100 text-yellow-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
pending: "bg-blue-100 text-blue-800",
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? value
|
||||
: date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const formatQuota = (remainingMb: number) => {
|
||||
if (remainingMb >= 1000) {
|
||||
return `${(remainingMb / 1000).toFixed(1)} GB`;
|
||||
}
|
||||
return `${remainingMb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
const FeatureToggleRow = ({ label, enabled }: { label: string; enabled: boolean }) => (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-700">{label}</span>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-1 rounded-full ${
|
||||
enabled ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LoadingCard = ({ embedded }: { embedded: boolean }) => (
|
||||
<div
|
||||
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-gray-100"} p-6 lg:p-8`}
|
||||
>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-full bg-gray-200" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2" />
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4 bg-gray-200 rounded" />
|
||||
<div className="h-4 bg-gray-200 rounded w-5/6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="h-24 bg-gray-200 rounded" />
|
||||
<div className="h-24 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorCard = ({ embedded, message }: { embedded: boolean; message: string }) => (
|
||||
<div
|
||||
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-red-100"} p-6 lg:p-8`}
|
||||
>
|
||||
<div className="text-center text-red-600 text-sm">{message}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function SimDetailsCard({
|
||||
simDetails,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
isLoading,
|
||||
error,
|
||||
embedded = false,
|
||||
showFeaturesSummary = true,
|
||||
}: SimDetailsCardProps) {
|
||||
const formatPlan = (code?: string) => {
|
||||
const formatted = formatPlanShort(code);
|
||||
// Remove "PASI" prefix if present
|
||||
return formatted?.replace(/^PASI\s*/, "") || formatted;
|
||||
};
|
||||
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 >= 1000) {
|
||||
return `${(quotaMb / 1000).toFixed(1)} GB`;
|
||||
}
|
||||
return `${quotaMb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingCard embedded={embedded} />;
|
||||
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 <ErrorCard embedded={embedded} message={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>
|
||||
);
|
||||
}
|
||||
|
||||
const planName = simDetails.planName || formatSimPlanShort(simDetails.planCode) || "SIM Plan";
|
||||
const statusIcon = STATUS_ICON_MAP[simDetails.status] ?? (
|
||||
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
|
||||
);
|
||||
const statusClass = STATUS_BADGE_CLASS_MAP[simDetails.status] ?? "bg-gray-100 text-gray-800";
|
||||
const containerClasses = embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100";
|
||||
// Modern eSIM details view with usage visualization
|
||||
if (simDetails.simType === "esim") {
|
||||
const remainingGB = simDetails.remainingQuotaMb / 1000;
|
||||
const totalGB = 1048.6; // Mock total - should come from API
|
||||
const usedGB = totalGB - remainingGB;
|
||||
const usagePercentage = (usedGB / totalGB) * 100;
|
||||
|
||||
return (
|
||||
<div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<DevicePhoneMobileIcon className="h-7 w-7 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">{planName}</h3>
|
||||
<p className="text-sm text-gray-600">Account #{simDetails.account}</p>
|
||||
// Usage Sparkline Component
|
||||
const UsageSparkline = ({ data }: { data: Array<{ date: string; usedMB: number }> }) => {
|
||||
const maxValue = Math.max(...data.map(d => d.usedMB), 1);
|
||||
const width = 80;
|
||||
const height = 16;
|
||||
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
const y = height - (d.usedMB / maxValue) * height;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="text-blue-500">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
points={points}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Usage Donut Component
|
||||
const UsageDonut = ({ size = 120 }: { size?: number }) => {
|
||||
const radius = (size - 16) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference - (usagePercentage / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgb(241 245 249)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgb(59 130 246)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute text-center">
|
||||
<div className="text-3xl font-semibold text-slate-900">{remainingGB.toFixed(1)}</div>
|
||||
<div className="text-sm text-slate-500 -mt-1">GB remaining</div>
|
||||
<div className="text-xs text-slate-400 mt-1">{usagePercentage.toFixed(1)}% used</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
|
||||
{/* Compact Header Bar */}
|
||||
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-flex px-3 py-1 text-xs font-medium rounded-full ${getStatusColor(simDetails.status)}`}
|
||||
>
|
||||
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-slate-900">
|
||||
{formatPlan(simDetails.planCode)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-600 mt-1">{simDetails.msisdn}</div>
|
||||
</div>
|
||||
|
||||
<div className={`${embedded ? "" : "px-6 py-6"}`}>
|
||||
{/* Usage Visualization */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<UsageDonut size={160} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h4 className="text-sm font-medium text-slate-900 mb-3">Recent Usage History</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ date: "Sep 29", usage: "0 MB" },
|
||||
{ date: "Sep 28", usage: "0 MB" },
|
||||
{ date: "Sep 27", usage: "0 MB" },
|
||||
].map((entry, index) => (
|
||||
<div key={index} className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-600">{entry.date}</span>
|
||||
<span className="text-slate-900">{entry.usage}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default view for physical SIM cards
|
||||
return (
|
||||
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
|
||||
{/* 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>
|
||||
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full ${statusClass}`}>
|
||||
{statusIcon}
|
||||
<span className="text-sm font-medium capitalize">{simDetails.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<section className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
{/* 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>
|
||||
<dl className="space-y-2 text-sm text-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">Phone Number</dt>
|
||||
<dd className="font-semibold text-gray-900">{simDetails.msisdn}</dd>
|
||||
<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>
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">SIM Type</dt>
|
||||
<dd className="font-semibold text-gray-900">{simDetails.simType}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">ICCID</dt>
|
||||
<dd className="font-mono text-gray-900 break-all">{simDetails.iccid}</dd>
|
||||
</div>
|
||||
{simDetails.eid && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">EID</dt>
|
||||
<dd className="font-mono text-gray-900 break-all">{simDetails.eid}</dd>
|
||||
|
||||
{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>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">Network Type</dt>
|
||||
<dd className="font-semibold text-gray-900">{simDetails.networkType}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
Data Remaining
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{formatQuota(simDetails.remainingQuotaMb)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Remaining allowance in current cycle</p>
|
||||
</section>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<section className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
Activation Timeline
|
||||
</h4>
|
||||
<dl className="space-y-2 text-sm text-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">Activated</dt>
|
||||
<dd>{formatDate(simDetails.activatedAt)}</dd>
|
||||
{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 className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">Expires</dt>
|
||||
<dd>{formatDate(simDetails.expiresAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showFeaturesSummary && (
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
||||
Service Features
|
||||
{/* 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>
|
||||
<FeatureToggleRow label="Voice Mail" enabled={simDetails.voiceMailEnabled} />
|
||||
<FeatureToggleRow label="Call Waiting" enabled={simDetails.callWaitingEnabled} />
|
||||
<FeatureToggleRow
|
||||
label="International Roaming"
|
||||
enabled={simDetails.internationalRoamingEnabled}
|
||||
/>
|
||||
</section>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export type { SimDetails };
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
buildSimFeaturesUpdatePayload,
|
||||
type SimFeatureToggleSnapshot,
|
||||
} from "@customer-portal/domain/sim";
|
||||
|
||||
interface SimFeatureTogglesProps {
|
||||
subscriptionId: number;
|
||||
@ -27,42 +23,37 @@ export function SimFeatureToggles({
|
||||
embedded = false,
|
||||
}: SimFeatureTogglesProps) {
|
||||
// Initial values
|
||||
const initial = useMemo<SimFeatureToggleSnapshot>(
|
||||
const initial = useMemo(
|
||||
() => ({
|
||||
voiceMailEnabled: !!voiceMailEnabled,
|
||||
callWaitingEnabled: !!callWaitingEnabled,
|
||||
internationalRoamingEnabled: !!internationalRoamingEnabled,
|
||||
networkType: networkType === "5G" ? "5G" : "4G",
|
||||
vm: !!voiceMailEnabled,
|
||||
cw: !!callWaitingEnabled,
|
||||
ir: !!internationalRoamingEnabled,
|
||||
nt: networkType === "5G" ? "5G" : "4G",
|
||||
}),
|
||||
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
|
||||
);
|
||||
|
||||
// Working values
|
||||
const [vm, setVm] = useState(initial.voiceMailEnabled);
|
||||
const [cw, setCw] = useState(initial.callWaitingEnabled);
|
||||
const [ir, setIr] = useState(initial.internationalRoamingEnabled);
|
||||
const [nt, setNt] = useState<"4G" | "5G">(initial.networkType);
|
||||
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);
|
||||
const successTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVm(initial.voiceMailEnabled);
|
||||
setCw(initial.callWaitingEnabled);
|
||||
setIr(initial.internationalRoamingEnabled);
|
||||
setNt(initial.networkType);
|
||||
}, [initial]);
|
||||
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 = () => {
|
||||
if (successTimerRef.current) {
|
||||
clearTimeout(successTimerRef.current);
|
||||
successTimerRef.current = null;
|
||||
}
|
||||
setVm(initial.voiceMailEnabled);
|
||||
setCw(initial.callWaitingEnabled);
|
||||
setIr(initial.internationalRoamingEnabled);
|
||||
setNt(initial.networkType);
|
||||
setVm(initial.vm);
|
||||
setCw(initial.cw);
|
||||
setIr(initial.ir);
|
||||
setNt(initial.nt as "4G" | "5G");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
};
|
||||
@ -72,21 +63,22 @@ export function SimFeatureToggles({
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const featurePayload = buildSimFeaturesUpdatePayload(initial, {
|
||||
voiceMailEnabled: vm,
|
||||
callWaitingEnabled: cw,
|
||||
internationalRoamingEnabled: ir,
|
||||
networkType: nt,
|
||||
});
|
||||
const featurePayload: {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: "4G" | "5G";
|
||||
} = {};
|
||||
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 (featurePayload) {
|
||||
if (Object.keys(featurePayload).length > 0) {
|
||||
await apiClient.POST("/api/subscriptions/{id}/sim/features", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: featurePayload,
|
||||
});
|
||||
} else {
|
||||
setSuccess("No changes detected");
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess("Changes submitted successfully");
|
||||
@ -95,224 +87,132 @@ export function SimFeatureToggles({
|
||||
setError(e instanceof Error ? e.message : "Failed to submit changes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (successTimerRef.current) {
|
||||
clearTimeout(successTimerRef.current);
|
||||
}
|
||||
successTimerRef.current = window.setTimeout(() => {
|
||||
setSuccess(null);
|
||||
successTimerRef.current = null;
|
||||
}, 3000);
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (successTimerRef.current) {
|
||||
clearTimeout(successTimerRef.current);
|
||||
successTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Service Options */}
|
||||
<div
|
||||
className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 overflow-hidden"}`}
|
||||
>
|
||||
<div className={`${embedded ? "" : "p-6"} space-y-6`}>
|
||||
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-100 shadow-md"}`}>
|
||||
<div className={`${embedded ? "" : "p-6"} space-y-4`}>
|
||||
{/* 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 items-center justify-between py-4">
|
||||
<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.voiceMailEnabled ? "text-green-600" : "text-gray-600"}`}
|
||||
>
|
||||
{initial.voiceMailEnabled ? "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 className="text-sm font-medium text-slate-900">Voice Mail</div>
|
||||
<div className="text-xs text-slate-500">¥300/month</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={vm}
|
||||
onClick={() => setVm(!vm)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||
vm ? "bg-blue-600" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
vm ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</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 items-center justify-between py-4">
|
||||
<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.callWaitingEnabled ? "text-green-600" : "text-gray-600"}`}
|
||||
>
|
||||
{initial.callWaitingEnabled ? "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 className="text-sm font-medium text-slate-900">Call Waiting</div>
|
||||
<div className="text-xs text-slate-500">¥300/month</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={cw}
|
||||
onClick={() => setCw(!cw)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||
cw ? "bg-blue-600" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
cw ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</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 items-center justify-between py-4">
|
||||
<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.internationalRoamingEnabled ? "text-green-600" : "text-gray-600"}`}
|
||||
>
|
||||
{initial.internationalRoamingEnabled ? "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 className="text-sm font-medium text-slate-900">International Roaming</div>
|
||||
<div className="text-xs text-slate-500">Global connectivity</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={ir}
|
||||
onClick={() => setIr(!ir)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||
ir ? "bg-blue-600" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
ir ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</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 className="border-t border-gray-200 pt-6">
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium text-slate-900 mb-1">Network Type</div>
|
||||
<div className="text-xs text-slate-500">Choose your preferred connectivity</div>
|
||||
<div className="text-xs text-red-600 mt-1">
|
||||
Voice, network, and plan changes must be requested at least 30 minutes apart. If you just changed another option, you may need to wait before submitting.
|
||||
</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.networkType}</span>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="4g"
|
||||
name="networkType"
|
||||
value="4G"
|
||||
checked={nt === "4G"}
|
||||
onChange={() => setNt("4G")}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<label htmlFor="4g" className="text-sm text-slate-700">
|
||||
4G
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="5g"
|
||||
name="networkType"
|
||||
value="5G"
|
||||
checked={nt === "5G"}
|
||||
onChange={() => setNt("5G")}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<label htmlFor="5g" className="text-sm text-slate-700">
|
||||
5G
|
||||
</label>
|
||||
</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>
|
||||
<p className="text-xs text-slate-500 mt-2">5G connectivity for enhanced speeds</p>
|
||||
</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"
|
||||
>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-2 flex items-center gap-2">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@ -320,21 +220,26 @@ export function SimFeatureToggles({
|
||||
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>
|
||||
Important Notes
|
||||
</h4>
|
||||
<ul className="text-xs text-blue-800 space-y-1">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||
Changes will take effect instantaneously (approx. 30min)
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||
May require smartphone device restart after changes are applied
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1 h-1 bg-red-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||
<span className="text-red-600">Voice, network, and plan changes must be requested at least 30 minutes apart.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||
Changes to Voice Mail / Call Waiting must be requested before the 25th of the month
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
|
||||
@ -1,84 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { SimDetailsCard } from "./SimDetailsCard";
|
||||
import { DataUsageChart } from "./DataUsageChart";
|
||||
import { SimActions } from "./SimActions";
|
||||
import { type SimDetails } from "./SimDetailsCard";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { SimFeatureToggles } from "./SimFeatureToggles";
|
||||
import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
|
||||
import Link from "next/link";
|
||||
|
||||
interface SimManagementSectionProps {
|
||||
subscriptionId: number;
|
||||
}
|
||||
|
||||
interface SimInfo {
|
||||
details: SimDetails;
|
||||
usage?: {
|
||||
todayUsageMb: number;
|
||||
recentDaysUsage: Array<{ date: string; usageMb: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
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 abortControllerRef = useRef<AbortController | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
const [activeTab, setActiveTab] = useState<"sim" | "invoices">("sim");
|
||||
|
||||
const fetchSimInfo = useCallback(async () => {
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{id}/sim", {
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.GET("/api/subscriptions/{id}/sim", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
const payload = response.data as { details: SimDetails; usage: any } | undefined;
|
||||
|
||||
if (!payload) {
|
||||
throw new Error("Failed to load SIM information");
|
||||
}
|
||||
|
||||
const payload = simInfoSchema.parse(response.data);
|
||||
|
||||
if (controller.signal.aborted || !isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSimInfo(payload);
|
||||
} catch (err: unknown) {
|
||||
if (controller.signal.aborted || !isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
const hasStatus = (value: unknown): value is { status: number } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"status" in value &&
|
||||
typeof (value as { status: unknown }).status === "number";
|
||||
|
||||
const hasStatus = (v: unknown): v is { status: number } =>
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
"status" in v &&
|
||||
typeof (v as { status: unknown }).status === "number";
|
||||
if (hasStatus(err) && err.status === 400) {
|
||||
// Not a SIM subscription - this component shouldn't be shown
|
||||
setError("This subscription is not a SIM service");
|
||||
return;
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||
} finally {
|
||||
if (!controller.signal.aborted && isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}, [subscriptionId]);
|
||||
|
||||
@ -87,35 +65,41 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
}, [fetchSimInfo]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true);
|
||||
void fetchSimInfo();
|
||||
};
|
||||
|
||||
const handleActionSuccess = () => {
|
||||
// Refresh SIM info after any successful action
|
||||
void 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 className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-white text-xl font-bold">Service Management</h1>
|
||||
<div className="flex gap-2">
|
||||
<div className="px-4 py-2 rounded-full border-2 border-white bg-white text-gray-900 text-sm font-medium">
|
||||
SIM Management
|
||||
</div>
|
||||
<div className="px-4 py-2 rounded-full border-2 border-white text-white text-sm font-medium">
|
||||
Invoices
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Loading Animation */}
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-24 bg-gray-200 rounded-2xl"></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-40 bg-gray-200 rounded-2xl"></div>
|
||||
<div className="h-40 bg-gray-200 rounded-2xl"></div>
|
||||
</div>
|
||||
<div className="h-12 bg-gray-200 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -124,27 +108,19 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
|
||||
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 className="min-h-screen bg-gray-50">
|
||||
<div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4">
|
||||
<h1 className="text-white text-xl font-bold">Service Management</h1>
|
||||
</div>
|
||||
<div className="text-center py-12">
|
||||
<div className="p-5 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>
|
||||
<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"
|
||||
className="inline-flex items-center px-6 py-3 rounded-full text-white bg-[#2F80ED] hover:bg-[#2671d9] font-semibold"
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5 mr-2" />
|
||||
Retry
|
||||
@ -158,106 +134,188 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
return null;
|
||||
}
|
||||
|
||||
const actionSimType: "esim" | "physical" =
|
||||
simInfo.details.simType.toLowerCase() === "esim" ? "esim" : "physical";
|
||||
const remainingGB = simInfo.details.remainingQuotaMb / 1000;
|
||||
const usedMB = simInfo.usage?.todayUsageMb || 0;
|
||||
const totalGB = 50; // Mock - should come from plan
|
||||
const usagePercent = ((totalGB * 1000 - simInfo.details.remainingQuotaMb) / (totalGB * 1000)) * 100;
|
||||
|
||||
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={actionSimType}
|
||||
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 id="sim-management" className="min-h-screen bg-gray-50">
|
||||
{/* 1. Top Header Bar */}
|
||||
<div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4 shadow-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-white text-[22px] font-bold">Service Management</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("sim")}
|
||||
className={`px-4 py-2 rounded-full border-2 border-white text-sm font-medium transition-all ${
|
||||
activeTab === "sim"
|
||||
? "bg-white text-gray-900"
|
||||
: "bg-transparent text-white"
|
||||
}`}
|
||||
>
|
||||
SIM Management
|
||||
</button>
|
||||
<Link
|
||||
href={`/subscriptions/${subscriptionId}`}
|
||||
className="px-4 py-2 rounded-full border-2 border-white text-white text-sm font-medium"
|
||||
>
|
||||
Invoices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-4">
|
||||
{/* 2. Billing Information Section */}
|
||||
<div className="bg-white rounded-2xl shadow-sm p-5">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Monthly Cost</p>
|
||||
<p className="text-base font-semibold text-[#1A1A1A]">¥3,100</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Next Billing</p>
|
||||
<p className="text-base font-semibold text-[#1A1A1A]">Jul 1 2025</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Registered</p>
|
||||
<p className="text-base font-semibold text-[#1A1A1A]">Aug 2 2023</p>
|
||||
</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>
|
||||
{/* 3. Invoice & Data Usage Row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Left Column - Invoice Card */}
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl p-5 flex flex-col justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-2">Latest Invoice</p>
|
||||
<p className="text-3xl font-bold text-[#2F80ED] mb-4">3400 ¥</p>
|
||||
</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>
|
||||
<button className="w-full bg-[#2F80ED] text-white font-semibold py-3 rounded-full hover:bg-[#2671d9] transition-colors">
|
||||
PAY
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* (On desktop, details+usage are above; on mobile they appear first since this section is above actions) */}
|
||||
{/* Right Column - Data Usage Circle */}
|
||||
<div className="bg-white rounded-2xl p-5 flex flex-col items-center justify-center">
|
||||
<p className="text-xs text-gray-500 mb-2">Remaining data</p>
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke="#E5E7EB"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke="#4B8CF7"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 56}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 56 * (1 - usagePercent / 100)}`}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{remainingGB.toFixed(1)} GB</p>
|
||||
<p className="text-sm text-[#D72828] font-medium">–{usedMB.toFixed(2)} GB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Top Up Button */}
|
||||
<button className="w-full bg-[#2F80ED] text-white font-semibold py-3.5 rounded-full hover:bg-[#2671d9] transition-colors shadow-md">
|
||||
Top Up Data
|
||||
</button>
|
||||
|
||||
{/* 5. SIM Management Actions Section */}
|
||||
<div className="bg-[#D7D7D7] rounded-2xl p-4">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-3">SIM Management Actions</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
|
||||
Top Up Data
|
||||
</button>
|
||||
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
|
||||
Change Plan
|
||||
</button>
|
||||
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
|
||||
Reissue SIM
|
||||
</button>
|
||||
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
|
||||
Cancel SIM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. Voice Status Section */}
|
||||
<div className="bg-[#D7D7D7] rounded-2xl p-4">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-3">Voice Status</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
className={`rounded-xl py-6 text-center font-medium transition-colors ${
|
||||
simInfo.details.voiceMailEnabled
|
||||
? "bg-blue-100 text-blue-700 border-2 border-blue-400"
|
||||
: "bg-[#E5E5E5] text-gray-900 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
Voice Mail
|
||||
</button>
|
||||
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
|
||||
Network Type
|
||||
<span className="block text-sm text-gray-600 mt-1">{simInfo.details.networkType || "4G"}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-xl py-6 text-center font-medium transition-colors ${
|
||||
simInfo.details.callWaitingEnabled
|
||||
? "bg-blue-100 text-blue-700 border-2 border-blue-400"
|
||||
: "bg-[#E5E5E5] text-gray-900 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
Call Waiting
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-xl py-6 text-center font-medium transition-colors ${
|
||||
simInfo.details.internationalRoamingEnabled
|
||||
? "bg-blue-100 text-blue-700 border-2 border-blue-400"
|
||||
: "bg-[#E5E5E5] text-gray-900 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
International Roaming
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7. Important Notes Section */}
|
||||
<div className="bg-white rounded-2xl shadow-sm p-5">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-3">Important Notes</h3>
|
||||
<ul className="space-y-2 text-[13px] text-[#7A7A7A] leading-relaxed">
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>Changes to SIM settings typically take effect instantaneously (approx. 30min)</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>May require smartphone device restart after changes are applied</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>Voice/Network/Plan change requests must be requested at least 30 minutes apart</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2">•</span>
|
||||
<span>Changes to Voice Mail / Call Waiting must be requested before the 25th of the month</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useSimTopUpPricing } from "../hooks/useSimTopUpPricing";
|
||||
import {
|
||||
simTopUpRequestSchema,
|
||||
type SimTopUpPricingPreviewResponse,
|
||||
} from "@customer-portal/domain/sim";
|
||||
|
||||
interface TopUpModalProps {
|
||||
subscriptionId: number;
|
||||
@ -19,22 +14,6 @@ interface TopUpModalProps {
|
||||
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
||||
const [gbAmount, setGbAmount] = useState<string>("1");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { pricing, loading: pricingLoading, calculatePreview } = useSimTopUpPricing();
|
||||
const [preview, setPreview] = useState<SimTopUpPricingPreviewResponse | null>(null);
|
||||
|
||||
// Update preview when gbAmount changes
|
||||
useEffect(() => {
|
||||
const updatePreview = async () => {
|
||||
const mb = parseInt(gbAmount, 10) * 1000;
|
||||
if (!isNaN(mb) && mb > 0) {
|
||||
const result = await calculatePreview(mb);
|
||||
setPreview(result);
|
||||
} else {
|
||||
setPreview(null);
|
||||
}
|
||||
};
|
||||
void updatePreview();
|
||||
}, [gbAmount, calculatePreview]);
|
||||
|
||||
const getCurrentAmountMb = () => {
|
||||
const gb = parseInt(gbAmount, 10);
|
||||
@ -42,39 +21,35 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
};
|
||||
|
||||
const isValidAmount = () => {
|
||||
if (!pricing) return false;
|
||||
const mb = getCurrentAmountMb();
|
||||
return mb >= pricing.minQuotaMb && mb <= pricing.maxQuotaMb;
|
||||
const gb = Number(gbAmount);
|
||||
return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB, whole numbers only (Freebit API limit)
|
||||
};
|
||||
|
||||
const displayCost = preview?.totalPriceJpy ?? 0;
|
||||
const pricePerGb = pricing?.pricePerGbJpy ?? 500;
|
||||
const calculateCost = () => {
|
||||
const gb = parseInt(gbAmount, 10);
|
||||
return isNaN(gb) ? 0 : gb * 500; // 1GB = 500 JPY
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isValidAmount()) {
|
||||
onError(
|
||||
`Please enter a valid amount between ${pricing ? pricing.minQuotaMb / 1000 : 1} GB and ${pricing ? pricing.maxQuotaMb / 1000 : 50} GB`
|
||||
);
|
||||
onError("Please enter a whole number between 1 GB and 100 GB");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const validationResult = simTopUpRequestSchema.safeParse({
|
||||
const requestBody = {
|
||||
quotaMb: getCurrentAmountMb(),
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
onError(validationResult.error.issues[0]?.message ?? "Invalid top-up amount");
|
||||
return;
|
||||
}
|
||||
amount: calculateCost(),
|
||||
currency: "JPY",
|
||||
};
|
||||
|
||||
await apiClient.POST("/api/subscriptions/{id}/sim/top-up", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: validationResult.data,
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
@ -137,9 +112,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Enter the amount of data you want to add (
|
||||
{pricing ? `${pricing.minQuotaMb / 1000} - ${pricing.maxQuotaMb / 1000}` : "1 - 50"}{" "}
|
||||
GB, whole numbers)
|
||||
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -154,21 +127,20 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-blue-900">
|
||||
¥{displayCost.toLocaleString()}
|
||||
¥{calculateCost().toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">(1GB = ¥{pricePerGb})</div>
|
||||
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Warning */}
|
||||
{!isValidAmount() && gbAmount && pricing && (
|
||||
{!isValidAmount() && gbAmount && (
|
||||
<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 {pricing.minQuotaMb / 1000} GB and{" "}
|
||||
{pricing.maxQuotaMb / 1000} GB
|
||||
Amount must be a whole number between 1 GB and 50 GB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -186,14 +158,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !isValidAmount() || pricingLoading}
|
||||
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..."
|
||||
: pricingLoading
|
||||
? "Loading..."
|
||||
: `Top Up Now - ¥${displayCost.toLocaleString()}`}
|
||||
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
62
apps/portal/src/features/sim-management/utils/plan.ts
Normal file
62
apps/portal/src/features/sim-management/utils/plan.ts
Normal file
@ -0,0 +1,62 @@
|
||||
// Generic plan code formatter for SIM plans
|
||||
// Examples:
|
||||
// - PASI_10G -> 10G
|
||||
// - PASI_25G -> 25G
|
||||
// - ANY_PREFIX_50GB -> 50G
|
||||
// - Fallback: return the original code when unknown
|
||||
|
||||
export function formatPlanShort(planCode?: string): string {
|
||||
if (!planCode) return "—";
|
||||
const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
|
||||
if (m && m[1]) {
|
||||
return `${m[1]}G`;
|
||||
}
|
||||
// Try extracting trailing number+G anywhere in the string
|
||||
const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
|
||||
if (m2 && m2[1]) {
|
||||
return `${m2[1]}G`;
|
||||
}
|
||||
return planCode;
|
||||
}
|
||||
|
||||
// Mapping between Freebit plan codes and Salesforce product SKUs used by the portal
|
||||
export const SIM_PLAN_SKU_BY_CODE: Record<string, string> = {
|
||||
PASI_5G: "SIM-DATA-VOICE-5GB",
|
||||
PASI_10G: "SIM-DATA-VOICE-10GB",
|
||||
PASI_25G: "SIM-DATA-VOICE-25GB",
|
||||
PASI_50G: "SIM-DATA-VOICE-50GB",
|
||||
PASI_5G_DATA: "SIM-DATA-ONLY-5GB",
|
||||
PASI_10G_DATA: "SIM-DATA-ONLY-10GB",
|
||||
PASI_25G_DATA: "SIM-DATA-ONLY-25GB",
|
||||
PASI_50G_DATA: "SIM-DATA-ONLY-50GB",
|
||||
};
|
||||
|
||||
export function getSimPlanSku(planCode?: string): string | undefined {
|
||||
if (!planCode) return undefined;
|
||||
return SIM_PLAN_SKU_BY_CODE[planCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Freebit plan codes to simplified format for API requests
|
||||
* Converts PASI_5G -> 5GB, PASI_25G -> 25GB, etc.
|
||||
*/
|
||||
export function mapToSimplifiedFormat(planCode?: string): string {
|
||||
if (!planCode) return "";
|
||||
|
||||
// Handle Freebit format (PASI_5G, PASI_25G, etc.)
|
||||
if (planCode.startsWith("PASI_")) {
|
||||
const match = planCode.match(/PASI_(\d+)G/);
|
||||
if (match) {
|
||||
return `${match[1]}GB`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other formats that might end with G or GB
|
||||
const match = planCode.match(/(\d+)\s*G(?:B)?\b/i);
|
||||
if (match) {
|
||||
return `${match[1]}GB`;
|
||||
}
|
||||
|
||||
// Return as-is if no pattern matches
|
||||
return planCode;
|
||||
}
|
||||
0
scripts/bundle-analyze.sh
Normal file → Executable file
0
scripts/bundle-analyze.sh
Normal file → Executable file
0
scripts/dev/manage.sh
Normal file → Executable file
0
scripts/dev/manage.sh
Normal file → Executable file
0
scripts/migrate-imports.sh
Normal file → Executable file
0
scripts/migrate-imports.sh
Normal file → Executable file
0
scripts/plesk-deploy.sh
Normal file → Executable file
0
scripts/plesk-deploy.sh
Normal file → Executable file
0
scripts/plesk/build-images.sh
Normal file → Executable file
0
scripts/plesk/build-images.sh
Normal file → Executable file
0
scripts/prod/manage.sh
Normal file → Executable file
0
scripts/prod/manage.sh
Normal file → Executable file
0
scripts/set-log-level.sh
Normal file → Executable file
0
scripts/set-log-level.sh
Normal file → Executable file
0
scripts/validate-deps.sh
Normal file → Executable file
0
scripts/validate-deps.sh
Normal file → Executable file
Loading…
x
Reference in New Issue
Block a user