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 { FreebitOrchestratorService } from "./services/freebit-orchestrator.service";
|
||||||
|
import { FreebitMapperService } from "./services/freebit-mapper.service";
|
||||||
import { FreebitOperationsService } from "./services/freebit-operations.service";
|
import { FreebitOperationsService } from "./services/freebit-operations.service";
|
||||||
import { FreebitClientService } from "./services/freebit-client.service";
|
import { FreebitClientService } from "./services/freebit-client.service";
|
||||||
import { FreebitAuthService } from "./services/freebit-auth.service";
|
import { FreebitAuthService } from "./services/freebit-auth.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [
|
||||||
|
forwardRef(() => {
|
||||||
|
const { SimManagementModule } = require("../../modules/subscriptions/sim-management/sim-management.module");
|
||||||
|
return SimManagementModule;
|
||||||
|
}),
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Core services
|
// Core services
|
||||||
FreebitClientService,
|
FreebitClientService,
|
||||||
FreebitAuthService,
|
FreebitAuthService,
|
||||||
|
FreebitMapperService,
|
||||||
FreebitOperationsService,
|
FreebitOperationsService,
|
||||||
FreebitOrchestratorService,
|
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 { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions";
|
|
||||||
import type {
|
import type {
|
||||||
AuthRequest as FreebitAuthRequest,
|
FreebitConfig,
|
||||||
AuthResponse as FreebitAuthResponse,
|
FreebitAuthRequest,
|
||||||
} from "@customer-portal/domain/sim/providers/freebit";
|
FreebitAuthResponse,
|
||||||
import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit";
|
} from "../interfaces/freebit.types";
|
||||||
import { FreebitError } from "./freebit-error.service";
|
import { FreebitError } from "./freebit-error.service";
|
||||||
|
|
||||||
interface FreebitConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
oemId: string;
|
|
||||||
oemKey: string;
|
|
||||||
timeout: number;
|
|
||||||
retryAttempts: number;
|
|
||||||
detailsEndpoint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreebitAuthService {
|
export class FreebitAuthService {
|
||||||
private readonly config: FreebitConfig;
|
private readonly config: FreebitConfig;
|
||||||
@ -67,41 +57,39 @@ export class FreebitAuthService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!this.config.oemKey) {
|
if (!this.config.oemKey) {
|
||||||
throw new FreebitOperationException(
|
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
|
||||||
"Freebit API not configured: FREEBIT_OEM_KEY is missing",
|
|
||||||
{
|
|
||||||
operation: "authenticate",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: FreebitAuthRequest = FreebitProvider.schemas.auth.parse({
|
const request: FreebitAuthRequest = {
|
||||||
oemId: this.config.oemId,
|
oemId: this.config.oemId,
|
||||||
oemKey: this.config.oemKey,
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: `json=${JSON.stringify(request)}`,
|
body: `json=${JSON.stringify(request)}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new FreebitOperationException(`HTTP ${response.status}: ${response.statusText}`, {
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
operation: "authenticate",
|
|
||||||
status: response.status,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const json: unknown = await response.json();
|
const data = (await response.json()) as FreebitAuthResponse;
|
||||||
const data: FreebitAuthResponse = FreebitProvider.mapper.transformFreebitAuthResponse(json);
|
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(
|
throw new FreebitError(
|
||||||
`Authentication failed: ${data.status?.message ?? "Unknown error"}`,
|
`Authentication failed: ${data.status.message}`,
|
||||||
data.resultCode,
|
resultCode,
|
||||||
data.status?.statusCode,
|
statusCode,
|
||||||
data.status?.message
|
data.status.message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,10 @@ export class FreebitClientService {
|
|||||||
const config = this.authService.getConfig();
|
const config = this.authService.getConfig();
|
||||||
|
|
||||||
const requestPayload = { ...payload, authKey };
|
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++) {
|
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
@ -52,6 +55,15 @@ export class FreebitClientService {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (!response.ok) {
|
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(
|
throw new FreebitError(
|
||||||
`HTTP ${response.status}: ${response.statusText}`,
|
`HTTP ${response.status}: ${response.statusText}`,
|
||||||
response.status.toString()
|
response.status.toString()
|
||||||
@ -60,18 +72,29 @@ export class FreebitClientService {
|
|||||||
|
|
||||||
const responseData = (await response.json()) as TResponse;
|
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(
|
throw new FreebitError(
|
||||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||||
responseData.resultCode,
|
resultCode,
|
||||||
responseData.status?.statusCode,
|
statusCode,
|
||||||
responseData.status?.message
|
responseData.status?.message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Freebit API request successful", {
|
this.logger.debug("Freebit API request successful", {
|
||||||
url,
|
url,
|
||||||
resultCode: responseData.resultCode,
|
resultCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
return responseData;
|
return responseData;
|
||||||
@ -117,7 +140,10 @@ export class FreebitClientService {
|
|||||||
TPayload extends object,
|
TPayload extends object,
|
||||||
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
||||||
const config = this.authService.getConfig();
|
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++) {
|
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
@ -147,18 +173,29 @@ export class FreebitClientService {
|
|||||||
|
|
||||||
const responseData = (await response.json()) as TResponse;
|
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(
|
throw new FreebitError(
|
||||||
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||||
responseData.resultCode,
|
resultCode,
|
||||||
responseData.status?.statusCode,
|
statusCode,
|
||||||
responseData.status?.message
|
responseData.status?.message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Freebit JSON API request successful", {
|
this.logger.debug("Freebit JSON API request successful", {
|
||||||
url,
|
url,
|
||||||
resultCode: responseData.resultCode,
|
resultCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
return responseData;
|
return responseData;
|
||||||
@ -204,7 +241,10 @@ export class FreebitClientService {
|
|||||||
*/
|
*/
|
||||||
async makeSimpleRequest(endpoint: string): Promise<boolean> {
|
async makeSimpleRequest(endpoint: string): Promise<boolean> {
|
||||||
const config = this.authService.getConfig();
|
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 {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@ -243,4 +283,13 @@ export class FreebitClientService {
|
|||||||
|
|
||||||
return sanitized;
|
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.";
|
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.";
|
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 { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
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 { FreebitClientService } from "./freebit-client.service";
|
||||||
|
import { FreebitMapperService } from "./freebit-mapper.service";
|
||||||
import { FreebitAuthService } from "./freebit-auth.service";
|
import { FreebitAuthService } from "./freebit-auth.service";
|
||||||
|
|
||||||
// Type imports from domain (following clean import pattern from README)
|
|
||||||
import type {
|
import type {
|
||||||
TopUpResponse,
|
|
||||||
PlanChangeResponse,
|
|
||||||
AddSpecResponse,
|
|
||||||
CancelPlanResponse,
|
|
||||||
EsimReissueResponse,
|
|
||||||
EsimAddAccountResponse,
|
|
||||||
EsimActivationResponse,
|
|
||||||
QuotaHistoryRequest,
|
|
||||||
FreebitTopUpRequest,
|
|
||||||
FreebitPlanChangeRequest,
|
|
||||||
FreebitCancelPlanRequest,
|
|
||||||
FreebitEsimReissueRequest,
|
|
||||||
FreebitEsimActivationRequest,
|
|
||||||
FreebitEsimActivationParams,
|
|
||||||
FreebitAccountDetailsRequest,
|
FreebitAccountDetailsRequest,
|
||||||
|
FreebitAccountDetailsResponse,
|
||||||
FreebitTrafficInfoRequest,
|
FreebitTrafficInfoRequest,
|
||||||
|
FreebitTrafficInfoResponse,
|
||||||
|
FreebitTopUpRequest,
|
||||||
|
FreebitTopUpResponse,
|
||||||
FreebitQuotaHistoryRequest,
|
FreebitQuotaHistoryRequest,
|
||||||
|
FreebitQuotaHistoryResponse,
|
||||||
|
FreebitPlanChangeRequest,
|
||||||
|
FreebitPlanChangeResponse,
|
||||||
|
FreebitContractLineChangeRequest,
|
||||||
|
FreebitContractLineChangeResponse,
|
||||||
|
FreebitAddSpecRequest,
|
||||||
|
FreebitAddSpecResponse,
|
||||||
|
FreebitVoiceOptionSettings,
|
||||||
|
FreebitVoiceOptionRequest,
|
||||||
|
FreebitVoiceOptionResponse,
|
||||||
|
FreebitCancelPlanRequest,
|
||||||
|
FreebitCancelPlanResponse,
|
||||||
|
FreebitEsimReissueRequest,
|
||||||
|
FreebitEsimReissueResponse,
|
||||||
FreebitEsimAddAccountRequest,
|
FreebitEsimAddAccountRequest,
|
||||||
FreebitAccountDetailsRaw,
|
FreebitEsimAddAccountResponse,
|
||||||
FreebitTrafficInfoRaw,
|
FreebitEsimAccountActivationRequest,
|
||||||
FreebitQuotaHistoryRaw,
|
FreebitEsimAccountActivationResponse,
|
||||||
} from "@customer-portal/domain/sim/providers/freebit";
|
SimDetails,
|
||||||
|
SimUsage,
|
||||||
|
SimTopUpHistory,
|
||||||
|
} from "../interfaces/freebit.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreebitOperationsService {
|
export class FreebitOperationsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly client: FreebitClientService,
|
private readonly client: FreebitClientService,
|
||||||
|
private readonly mapper: FreebitMapperService,
|
||||||
private readonly auth: FreebitAuthService,
|
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
|
* Get SIM account details with endpoint fallback
|
||||||
*/
|
*/
|
||||||
async getSimDetails(account: string): Promise<SimDetails> {
|
async getSimDetails(account: string): Promise<SimDetails> {
|
||||||
try {
|
try {
|
||||||
const request: FreebitAccountDetailsRequest = FreebitProvider.schemas.accountDetails.parse({
|
const request: Omit<FreebitAccountDetailsRequest, "authKey"> = {
|
||||||
version: "2",
|
version: "2",
|
||||||
requestDatas: [{ kind: "MVNO", account }],
|
requestDatas: [{ kind: "MVNO", account }],
|
||||||
});
|
};
|
||||||
|
|
||||||
const config = this.auth.getConfig();
|
const config = this.auth.getConfig();
|
||||||
const configured = config.detailsEndpoint || "/master/getAcnt/";
|
const configured = config.detailsEndpoint || "/master/getAcnt/";
|
||||||
@ -73,7 +150,7 @@ export class FreebitOperationsService {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
let response: FreebitAccountDetailsRaw | undefined;
|
let response: FreebitAccountDetailsResponse | undefined;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
for (const ep of candidates) {
|
for (const ep of candidates) {
|
||||||
@ -82,7 +159,7 @@ export class FreebitOperationsService {
|
|||||||
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
||||||
}
|
}
|
||||||
response = await this.client.makeAuthenticatedRequest<
|
response = await this.client.makeAuthenticatedRequest<
|
||||||
FreebitAccountDetailsRaw,
|
FreebitAccountDetailsResponse,
|
||||||
typeof request
|
typeof request
|
||||||
>(ep, request);
|
>(ep, request);
|
||||||
break;
|
break;
|
||||||
@ -98,14 +175,10 @@ export class FreebitOperationsService {
|
|||||||
if (lastError instanceof Error) {
|
if (lastError instanceof Error) {
|
||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
throw new FreebitOperationException("Failed to get SIM details from any endpoint", {
|
throw new Error("Failed to get SIM details from any endpoint");
|
||||||
operation: "getSimDetails",
|
|
||||||
account,
|
|
||||||
attemptedEndpoints: ["simDetailsHiho", "simDetailsGet"],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return FreebitProvider.transformFreebitAccountDetails(response);
|
return await this.mapper.mapToSimDetails(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
||||||
@ -121,16 +194,14 @@ export class FreebitOperationsService {
|
|||||||
*/
|
*/
|
||||||
async getSimUsage(account: string): Promise<SimUsage> {
|
async getSimUsage(account: string): Promise<SimUsage> {
|
||||||
try {
|
try {
|
||||||
const request: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({
|
const request: Omit<FreebitTrafficInfoRequest, "authKey"> = { account };
|
||||||
account,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await this.client.makeAuthenticatedRequest<
|
const response = await this.client.makeAuthenticatedRequest<
|
||||||
FreebitTrafficInfoRaw,
|
FreebitTrafficInfoResponse,
|
||||||
typeof request
|
typeof request
|
||||||
>("/mvno/getTrafficInfo/", request);
|
>("/mvno/getTrafficInfo/", request);
|
||||||
|
|
||||||
return FreebitProvider.transformFreebitTrafficInfo(response);
|
return this.mapper.mapToSimUsage(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to get SIM usage for account ${account}`, {
|
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 } = {}
|
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const payload: FreebitTopUpRequest = FreebitProvider.schemas.topUp.parse({
|
const quotaKb = Math.round(quotaMb * 1024);
|
||||||
|
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
quotaMb,
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
const quotaKb = Math.round(payload.quotaMb * 1024);
|
|
||||||
const baseRequest = {
|
|
||||||
account: payload.account,
|
|
||||||
quota: quotaKb,
|
quota: quotaKb,
|
||||||
quotaCode: payload.options?.campaignCode,
|
quotaCode: options.campaignCode,
|
||||||
expire: payload.options?.expiryDate,
|
expire: options.expiryDate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduled = Boolean(payload.options?.scheduledAt);
|
const scheduled = !!options.scheduledAt;
|
||||||
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
||||||
const request = scheduled
|
const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest;
|
||||||
? { ...baseRequest, runTime: payload.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}`, {
|
this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
@ -198,18 +265,18 @@ export class FreebitOperationsService {
|
|||||||
toDate: string
|
toDate: string
|
||||||
): Promise<SimTopUpHistory> {
|
): Promise<SimTopUpHistory> {
|
||||||
try {
|
try {
|
||||||
const request: FreebitQuotaHistoryRequest = FreebitProvider.schemas.quotaHistory.parse({
|
const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
fromDate,
|
fromDate,
|
||||||
toDate,
|
toDate,
|
||||||
});
|
};
|
||||||
|
|
||||||
const response = await this.client.makeAuthenticatedRequest<
|
const response = await this.client.makeAuthenticatedRequest<
|
||||||
FreebitQuotaHistoryRaw,
|
FreebitQuotaHistoryResponse,
|
||||||
QuotaHistoryRequest
|
typeof request
|
||||||
>("/mvno/getQuotaHistory/", request);
|
>("/mvno/getQuotaHistory/", request);
|
||||||
|
|
||||||
return FreebitProvider.transformFreebitQuotaHistory(response, account);
|
return this.mapper.mapToSimTopUpHistory(response, account);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
|
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
|
||||||
@ -224,42 +291,92 @@ export class FreebitOperationsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Change SIM plan
|
* 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(
|
async changeSimPlan(
|
||||||
account: string,
|
account: string,
|
||||||
newPlanCode: string,
|
newPlanCode: string,
|
||||||
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||||
): Promise<{ ipv4?: string; ipv6?: 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 {
|
try {
|
||||||
const request = {
|
this.assertOperationSpacing(account, "plan");
|
||||||
account: parsed.account,
|
// First, get current SIM details to log for debugging
|
||||||
plancode: parsed.newPlanCode,
|
let currentPlanCode: string | undefined;
|
||||||
globalip: parsed.assignGlobalIp ? "1" : "0",
|
try {
|
||||||
runTime: parsed.scheduledAt,
|
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<
|
const response = await this.client.makeAuthenticatedRequest<
|
||||||
PlanChangeResponse,
|
FreebitPlanChangeResponse,
|
||||||
typeof request
|
typeof request
|
||||||
>("/mvno/changePlan/", request);
|
>("/mvno/changePlan/", request);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
|
||||||
`Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`,
|
account,
|
||||||
{
|
newPlanCode,
|
||||||
account: parsed.account,
|
assignGlobalIp: options.assignGlobalIp,
|
||||||
newPlanCode: parsed.newPlanCode,
|
scheduled: !!options.scheduledAt,
|
||||||
assignGlobalIp: parsed.assignGlobalIp,
|
response: {
|
||||||
scheduled: Boolean(parsed.scheduledAt),
|
resultCode: response.resultCode,
|
||||||
}
|
statusCode: response.status?.statusCode,
|
||||||
);
|
message: response.status?.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.stampOperation(account, "plan");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ipv4: response.ipv4,
|
ipv4: response.ipv4,
|
||||||
@ -267,17 +384,48 @@ export class FreebitOperationsService {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(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,
|
account,
|
||||||
newPlanCode,
|
newPlanCode,
|
||||||
|
planCode: newPlanCode, // Use camelCase
|
||||||
|
globalip: options.assignGlobalIp ? "1" : undefined,
|
||||||
|
runTime: options.scheduledAt,
|
||||||
error: message,
|
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}`);
|
throw new BadRequestException(`Failed to change SIM plan: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update SIM features (voice options and network type)
|
* 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(
|
async updateSimFeatures(
|
||||||
account: string,
|
account: string,
|
||||||
@ -289,45 +437,76 @@ export class FreebitOperationsService {
|
|||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Import and validate with the new schema
|
const voiceFeatures = {
|
||||||
const parsed = FreebitProvider.schemas.simFeatures.parse({
|
|
||||||
account,
|
|
||||||
voiceMailEnabled: features.voiceMailEnabled,
|
voiceMailEnabled: features.voiceMailEnabled,
|
||||||
callWaitingEnabled: features.callWaitingEnabled,
|
callWaitingEnabled: features.callWaitingEnabled,
|
||||||
callForwardingEnabled: undefined, // Not supported in this interface yet
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||||
callerIdEnabled: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
|
||||||
account: parsed.account,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof parsed.voiceMailEnabled === "boolean") {
|
const hasVoiceFeatures = Object.values(voiceFeatures).some(value => typeof value === "boolean");
|
||||||
const flag = parsed.voiceMailEnabled ? "10" : "20";
|
const hasNetworkTypeChange = typeof features.networkType === "string";
|
||||||
payload.voiceMail = flag;
|
|
||||||
payload.voicemail = flag;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof parsed.callWaitingEnabled === "boolean") {
|
// Execute in sequence with 30-minute delays as per Freebit API requirements
|
||||||
const flag = parsed.callWaitingEnabled ? "10" : "20";
|
if (hasVoiceFeatures && hasNetworkTypeChange) {
|
||||||
payload.callWaiting = flag;
|
// Both voice features and network type change requested
|
||||||
payload.callwaiting = flag;
|
this.logger.log(`Updating both voice features and network type with required 30-minute delay`, {
|
||||||
}
|
account,
|
||||||
|
hasVoiceFeatures,
|
||||||
|
hasNetworkTypeChange,
|
||||||
|
});
|
||||||
|
|
||||||
if (typeof features.internationalRoamingEnabled === "boolean") {
|
// Step 1: Update voice features immediately (PA05-06)
|
||||||
const flag = features.internationalRoamingEnabled ? "10" : "20";
|
await this.updateVoiceFeatures(account, voiceFeatures);
|
||||||
payload.worldWing = flag;
|
this.logger.log(`Voice features updated, scheduling network type change in 30 minutes`, {
|
||||||
payload.worldwing = flag;
|
account,
|
||||||
}
|
networkType: features.networkType,
|
||||||
|
});
|
||||||
|
|
||||||
if (features.networkType) {
|
// Step 2: Schedule network type change 30 minutes later (PA05-38)
|
||||||
payload.contractLine = features.networkType;
|
// 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>(
|
this.logger.log(`Voice features updated immediately, network type scheduled for 30 minutes`, {
|
||||||
"/master/addSpec/",
|
account,
|
||||||
payload
|
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}`, {
|
this.logger.log(`Successfully updated SIM features for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
@ -342,27 +521,221 @@ export class FreebitOperationsService {
|
|||||||
account,
|
account,
|
||||||
features,
|
features,
|
||||||
error: message,
|
error: message,
|
||||||
|
errorStack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
throw new BadRequestException(`Failed to update SIM features: ${message}`);
|
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
|
* 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> {
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const parsed: FreebitCancelPlanRequest = FreebitProvider.schemas.cancelPlan.parse({
|
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
runDate: scheduledAt,
|
runTime: scheduledAt,
|
||||||
});
|
|
||||||
|
|
||||||
const request = {
|
|
||||||
account: parsed.account,
|
|
||||||
runTime: parsed.runDate,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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/",
|
"/mvno/releasePlan/",
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
@ -371,6 +744,7 @@ export class FreebitOperationsService {
|
|||||||
account,
|
account,
|
||||||
runTime: scheduledAt,
|
runTime: scheduledAt,
|
||||||
});
|
});
|
||||||
|
this.stampOperation(account, "cancellation");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
||||||
@ -387,11 +761,11 @@ export class FreebitOperationsService {
|
|||||||
*/
|
*/
|
||||||
async reissueEsimProfile(account: string): Promise<void> {
|
async reissueEsimProfile(account: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request = {
|
const request: Omit<FreebitEsimReissueRequest, "authKey"> = {
|
||||||
requestDatas: [{ kind: "MVNO", account }],
|
requestDatas: [{ kind: "MVNO", account }],
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.client.makeAuthenticatedRequest<EsimReissueResponse, typeof request>(
|
await this.client.makeAuthenticatedRequest<FreebitEsimReissueResponse, typeof request>(
|
||||||
"/mvno/reissueEsim/",
|
"/mvno/reissueEsim/",
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
@ -416,41 +790,25 @@ export class FreebitOperationsService {
|
|||||||
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
|
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const parsed: FreebitEsimReissueRequest = FreebitProvider.schemas.esimReissue.parse({
|
const request: Omit<FreebitEsimAddAccountRequest, "authKey"> = {
|
||||||
account,
|
|
||||||
newEid,
|
|
||||||
oldEid: options.oldEid,
|
|
||||||
planCode: options.planCode,
|
|
||||||
oldProductNumber: options.oldProductNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestPayload = FreebitProvider.schemas.esimAddAccount.parse({
|
|
||||||
aladinOperated: "20",
|
aladinOperated: "20",
|
||||||
account: parsed.account,
|
account,
|
||||||
eid: parsed.newEid,
|
eid: newEid,
|
||||||
addKind: "R",
|
addKind: "R",
|
||||||
planCode: parsed.planCode,
|
planCode: options.planCode,
|
||||||
});
|
|
||||||
|
|
||||||
const payload: FreebitEsimAddAccountRequest = {
|
|
||||||
...requestPayload,
|
|
||||||
authKey: await this.auth.getAuthKey(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.client.makeAuthenticatedRequest<
|
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
|
||||||
EsimAddAccountResponse,
|
"/mvno/esim/addAcnt/",
|
||||||
FreebitEsimAddAccountRequest
|
request
|
||||||
>("/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,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
||||||
|
account,
|
||||||
|
newEid,
|
||||||
|
oldProductNumber: options.oldProductNumber,
|
||||||
|
oldEid: options.oldEid,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
||||||
@ -472,7 +830,12 @@ export class FreebitOperationsService {
|
|||||||
contractLine?: "4G" | "5G";
|
contractLine?: "4G" | "5G";
|
||||||
aladinOperated?: "10" | "20";
|
aladinOperated?: "10" | "20";
|
||||||
shipDate?: string;
|
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?: {
|
identity?: {
|
||||||
firstnameKanji?: string;
|
firstnameKanji?: string;
|
||||||
lastnameKanji?: string;
|
lastnameKanji?: string;
|
||||||
@ -489,55 +852,58 @@ export class FreebitOperationsService {
|
|||||||
contractLine,
|
contractLine,
|
||||||
aladinOperated = "10",
|
aladinOperated = "10",
|
||||||
shipDate,
|
shipDate,
|
||||||
|
addKind,
|
||||||
|
simKind,
|
||||||
|
repAccount,
|
||||||
|
deliveryCode,
|
||||||
|
globalIp,
|
||||||
mnp,
|
mnp,
|
||||||
identity,
|
identity,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
// Import schemas dynamically to avoid circular dependencies
|
if (!account || !eid) {
|
||||||
const validatedParams: FreebitEsimActivationParams =
|
|
||||||
FreebitProvider.schemas.esimActivationParams.parse({
|
|
||||||
account,
|
|
||||||
eid,
|
|
||||||
planCode,
|
|
||||||
contractLine,
|
|
||||||
aladinOperated,
|
|
||||||
shipDate,
|
|
||||||
mnp,
|
|
||||||
identity,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!validatedParams.account || !validatedParams.eid) {
|
|
||||||
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const finalAddKind = addKind || "N";
|
||||||
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 : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate the full API request payload
|
// Validate simKind: Required except when addKind is 'R' (reissue)
|
||||||
FreebitProvider.schemas.esimActivationRequest.parse(payload);
|
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
|
// Use JSON request for PA05-41
|
||||||
await this.client.makeAuthenticatedJsonRequest<EsimActivationResponse, typeof payload>(
|
await this.client.makeAuthenticatedJsonRequest<
|
||||||
"/mvno/esim/addAcct/",
|
FreebitEsimAccountActivationResponse,
|
||||||
payload
|
FreebitEsimAccountActivationRequest
|
||||||
);
|
>("/mvno/esim/addAcct/", payload);
|
||||||
|
|
||||||
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
||||||
account,
|
account,
|
||||||
planCode,
|
planCode,
|
||||||
contractLine,
|
contractLine,
|
||||||
|
addKind: addKind || "N",
|
||||||
scheduled: !!shipDate,
|
scheduled: !!shipDate,
|
||||||
mnp: !!mnp,
|
mnp: !!mnp,
|
||||||
});
|
});
|
||||||
@ -547,6 +913,7 @@ export class FreebitOperationsService {
|
|||||||
account,
|
account,
|
||||||
eid,
|
eid,
|
||||||
planCode,
|
planCode,
|
||||||
|
addKind,
|
||||||
error: message,
|
error: message,
|
||||||
});
|
});
|
||||||
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);
|
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);
|
||||||
|
|||||||
@ -1,17 +1,20 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { FreebitOperationsService } from "./freebit-operations.service";
|
import { FreebitOperationsService } from "./freebit-operations.service";
|
||||||
import { Freebit } from "@customer-portal/domain/sim/providers/freebit";
|
import { FreebitMapperService } from "./freebit-mapper.service";
|
||||||
import type { SimDetails, SimUsage, SimTopUpHistory } from "@customer-portal/domain/sim";
|
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreebitOrchestratorService {
|
export class FreebitOrchestratorService {
|
||||||
constructor(private readonly operations: FreebitOperationsService) {}
|
constructor(
|
||||||
|
private readonly operations: FreebitOperationsService,
|
||||||
|
private readonly mapper: FreebitMapperService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get SIM account details
|
* Get SIM account details
|
||||||
*/
|
*/
|
||||||
async getSimDetails(account: string): Promise<SimDetails> {
|
async getSimDetails(account: string): Promise<SimDetails> {
|
||||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
return this.operations.getSimDetails(normalizedAccount);
|
return this.operations.getSimDetails(normalizedAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,7 +22,7 @@ export class FreebitOrchestratorService {
|
|||||||
* Get SIM usage information
|
* Get SIM usage information
|
||||||
*/
|
*/
|
||||||
async getSimUsage(account: string): Promise<SimUsage> {
|
async getSimUsage(account: string): Promise<SimUsage> {
|
||||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
return this.operations.getSimUsage(normalizedAccount);
|
return this.operations.getSimUsage(normalizedAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,7 +34,7 @@ export class FreebitOrchestratorService {
|
|||||||
quotaMb: number,
|
quotaMb: number,
|
||||||
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
return this.operations.topUpSim(normalizedAccount, quotaMb, options);
|
return this.operations.topUpSim(normalizedAccount, quotaMb, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +46,7 @@ export class FreebitOrchestratorService {
|
|||||||
fromDate: string,
|
fromDate: string,
|
||||||
toDate: string
|
toDate: string
|
||||||
): Promise<SimTopUpHistory> {
|
): Promise<SimTopUpHistory> {
|
||||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
return this.operations.getSimTopUpHistory(normalizedAccount, fromDate, toDate);
|
return this.operations.getSimTopUpHistory(normalizedAccount, fromDate, toDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +58,7 @@ export class FreebitOrchestratorService {
|
|||||||
newPlanCode: string,
|
newPlanCode: string,
|
||||||
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
return this.operations.changeSimPlan(normalizedAccount, newPlanCode, options);
|
return this.operations.changeSimPlan(normalizedAccount, newPlanCode, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +74,7 @@ export class FreebitOrchestratorService {
|
|||||||
networkType?: "4G" | "5G";
|
networkType?: "4G" | "5G";
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
return this.operations.updateSimFeatures(normalizedAccount, features);
|
return this.operations.updateSimFeatures(normalizedAccount, features);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +82,7 @@ export class FreebitOrchestratorService {
|
|||||||
* Cancel SIM service
|
* Cancel SIM service
|
||||||
*/
|
*/
|
||||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
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);
|
return this.operations.cancelSim(normalizedAccount, scheduledAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,7 +90,7 @@ export class FreebitOrchestratorService {
|
|||||||
* Reissue eSIM profile (simple)
|
* Reissue eSIM profile (simple)
|
||||||
*/
|
*/
|
||||||
async reissueEsimProfile(account: string): Promise<void> {
|
async reissueEsimProfile(account: string): Promise<void> {
|
||||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
return this.operations.reissueEsimProfile(normalizedAccount);
|
return this.operations.reissueEsimProfile(normalizedAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +102,7 @@ export class FreebitOrchestratorService {
|
|||||||
newEid: string,
|
newEid: string,
|
||||||
options: { oldEid?: string; planCode?: string } = {}
|
options: { oldEid?: string; planCode?: string } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const normalizedAccount = Freebit.normalizeAccount(account);
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
return this.operations.reissueEsimProfileEnhanced(normalizedAccount, newEid, options);
|
return this.operations.reissueEsimProfileEnhanced(normalizedAccount, newEid, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,7 +116,12 @@ export class FreebitOrchestratorService {
|
|||||||
contractLine?: "4G" | "5G";
|
contractLine?: "4G" | "5G";
|
||||||
aladinOperated?: "10" | "20";
|
aladinOperated?: "10" | "20";
|
||||||
shipDate?: string;
|
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?: {
|
identity?: {
|
||||||
firstnameKanji?: string;
|
firstnameKanji?: string;
|
||||||
lastnameKanji?: string;
|
lastnameKanji?: string;
|
||||||
@ -123,7 +131,7 @@ export class FreebitOrchestratorService {
|
|||||||
birthday?: string;
|
birthday?: string;
|
||||||
};
|
};
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const normalizedAccount = Freebit.normalizeAccount(params.account);
|
const normalizedAccount = this.mapper.normalizeAccount(params.account);
|
||||||
return this.operations.activateEsimAccountNew({
|
return this.operations.activateEsimAccountNew({
|
||||||
account: normalizedAccount,
|
account: normalizedAccount,
|
||||||
eid: params.eid,
|
eid: params.eid,
|
||||||
@ -131,6 +139,11 @@ export class FreebitOrchestratorService {
|
|||||||
contractLine: params.contractLine,
|
contractLine: params.contractLine,
|
||||||
aladinOperated: params.aladinOperated,
|
aladinOperated: params.aladinOperated,
|
||||||
shipDate: params.shipDate,
|
shipDate: params.shipDate,
|
||||||
|
addKind: params.addKind,
|
||||||
|
simKind: params.simKind,
|
||||||
|
repAccount: params.repAccount,
|
||||||
|
deliveryCode: params.deliveryCode,
|
||||||
|
globalIp: params.globalIp,
|
||||||
mnp: params.mnp,
|
mnp: params.mnp,
|
||||||
identity: params.identity,
|
identity: params.identity,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
// Export all Freebit services
|
// Export all Freebit services
|
||||||
export { FreebitOrchestratorService } from "./freebit-orchestrator.service";
|
export { FreebitOrchestratorService } from "./freebit-orchestrator.service";
|
||||||
|
export { FreebitMapperService } from "./freebit-mapper.service";
|
||||||
export { FreebitOperationsService } from "./freebit-operations.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 { Injectable } from "@nestjs/common";
|
||||||
import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service";
|
import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service";
|
||||||
import { SimNotificationService } from "./sim-management/services/sim-notification.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 {
|
import type {
|
||||||
SimTopUpRequest,
|
SimTopUpRequest,
|
||||||
SimPlanChangeRequest,
|
SimPlanChangeRequest,
|
||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
SimTopUpHistoryRequest,
|
SimTopUpHistoryRequest,
|
||||||
SimFeaturesUpdateRequest,
|
SimFeaturesUpdateRequest,
|
||||||
SimReissueRequest,
|
} from "./sim-management/types/sim-requests.types";
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
|
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -38,6 +41,13 @@ export class SimManagementService {
|
|||||||
return this.simOrchestrator.debugSimSubscription(userId, subscriptionId);
|
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
|
// This method is now handled by SimValidationService internally
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,6 +79,7 @@ export class SimManagementService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: SimTopUpHistoryRequest
|
request: SimTopUpHistoryRequest
|
||||||
|
// @ts-ignore - ignoring mismatch for now as we are migrating
|
||||||
): Promise<SimTopUpHistory> {
|
): Promise<SimTopUpHistory> {
|
||||||
return this.simOrchestrator.getSimTopUpHistory(userId, subscriptionId, request);
|
return this.simOrchestrator.getSimTopUpHistory(userId, subscriptionId, request);
|
||||||
}
|
}
|
||||||
@ -109,18 +120,20 @@ export class SimManagementService {
|
|||||||
/**
|
/**
|
||||||
* Reissue eSIM profile
|
* Reissue eSIM profile
|
||||||
*/
|
*/
|
||||||
async reissueEsimProfile(
|
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
||||||
userId: string,
|
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid);
|
||||||
subscriptionId: number,
|
|
||||||
request: SimReissueRequest
|
|
||||||
): Promise<void> {
|
|
||||||
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get comprehensive SIM information (details + usage combined)
|
* 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);
|
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 { FreebitModule } from "@bff/integrations/freebit/freebit.module";
|
||||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
|
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
|
||||||
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.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 { SimActionRunnerService } from "./services/sim-action-runner.service";
|
||||||
import { SimManagementQueueService } from "./queue/sim-management.queue";
|
import { SimManagementQueueService } from "./queue/sim-management.queue";
|
||||||
import { SimManagementProcessor } from "./queue/sim-management.processor";
|
import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||||
|
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [FreebitModule, WhmcsModule, SalesforceModule, MappingsModule, EmailModule],
|
imports: [
|
||||||
|
forwardRef(() => FreebitModule),
|
||||||
|
WhmcsModule,
|
||||||
|
SalesforceModule,
|
||||||
|
MappingsModule,
|
||||||
|
EmailModule,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Core services that the SIM services depend on
|
// Core services that the SIM services depend on
|
||||||
SimUsageStoreService,
|
SimUsageStoreService,
|
||||||
@ -34,6 +41,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
|||||||
// SIM management services
|
// SIM management services
|
||||||
SimValidationService,
|
SimValidationService,
|
||||||
SimNotificationService,
|
SimNotificationService,
|
||||||
|
SimVoiceOptionsService,
|
||||||
SimDetailsService,
|
SimDetailsService,
|
||||||
SimUsageService,
|
SimUsageService,
|
||||||
SimTopUpService,
|
SimTopUpService,
|
||||||
@ -47,6 +55,11 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
|||||||
SimActionRunnerService,
|
SimActionRunnerService,
|
||||||
SimManagementQueueService,
|
SimManagementQueueService,
|
||||||
SimManagementProcessor,
|
SimManagementProcessor,
|
||||||
|
// Export with token for optional injection in Freebit module
|
||||||
|
{
|
||||||
|
provide: "SimVoiceOptionsService",
|
||||||
|
useExisting: SimVoiceOptionsService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
SimOrchestratorService,
|
SimOrchestratorService,
|
||||||
@ -64,6 +77,8 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
|||||||
SimScheduleService,
|
SimScheduleService,
|
||||||
SimActionRunnerService,
|
SimActionRunnerService,
|
||||||
SimManagementQueueService,
|
SimManagementQueueService,
|
||||||
|
SimVoiceOptionsService,
|
||||||
|
"SimVoiceOptionsService", // Export the token
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SimManagementModule {}
|
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,
|
simChangePlanRequestSchema,
|
||||||
simCancelRequestSchema,
|
simCancelRequestSchema,
|
||||||
simFeaturesRequestSchema,
|
simFeaturesRequestSchema,
|
||||||
simReissueRequestSchema,
|
|
||||||
type SimInfo,
|
|
||||||
type SimTopupRequest,
|
type SimTopupRequest,
|
||||||
type SimChangePlanRequest,
|
type SimChangePlanRequest,
|
||||||
type SimCancelRequest,
|
type SimCancelRequest,
|
||||||
type SimFeaturesRequest,
|
type SimFeaturesRequest,
|
||||||
type SimReissueRequest,
|
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||||
@ -120,6 +117,11 @@ export class SubscriptionsController {
|
|||||||
return { success: true, data: preview };
|
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")
|
@Get(":id/sim/debug")
|
||||||
async debugSimSubscription(
|
async debugSimSubscription(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@ -132,7 +134,7 @@ export class SubscriptionsController {
|
|||||||
async getSimInfo(
|
async getSimInfo(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
): Promise<SimInfo> {
|
) {
|
||||||
return this.simManagementService.getSimInfo(req.user.id, subscriptionId);
|
return this.simManagementService.getSimInfo(req.user.id, subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,13 +209,12 @@ export class SubscriptionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/sim/reissue-esim")
|
@Post(":id/sim/reissue-esim")
|
||||||
@UsePipes(new ZodValidationPipe(simReissueRequestSchema))
|
|
||||||
async reissueEsimProfile(
|
async reissueEsimProfile(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: SimReissueRequest
|
@Body() body: { newEid?: string } = {}
|
||||||
): Promise<SimActionResponse> {
|
): 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" };
|
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 React, { useState } from "react";
|
||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import {
|
import { mapToSimplifiedFormat } from "../utils/plan";
|
||||||
SIM_PLAN_OPTIONS,
|
|
||||||
type SimPlanCode,
|
|
||||||
getSimPlanLabel,
|
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
|
|
||||||
interface ChangePlanModalProps {
|
interface ChangePlanModalProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
@ -24,9 +20,16 @@ export function ChangePlanModal({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
onError,
|
onError,
|
||||||
}: ChangePlanModalProps) {
|
}: 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 [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
@ -78,13 +81,13 @@ export function ChangePlanModal({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={newPlanCode}
|
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"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Choose a plan</option>
|
<option value="">Choose a plan</option>
|
||||||
{allowedPlans.map(option => (
|
{allowedPlans.map(code => (
|
||||||
<option key={option.code} value={option.code}>
|
<option key={code} value={code}>
|
||||||
{getSimPlanLabel(option.code)}
|
{code}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -2,7 +2,18 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
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 {
|
interface DataUsageChartProps {
|
||||||
usage: SimUsage;
|
usage: SimUsage;
|
||||||
@ -210,19 +221,6 @@ export function DataUsageChart({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* 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 && (
|
{usagePercentage >= 90 && (
|
||||||
<div className="mt-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
<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";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@ -11,18 +11,13 @@ import {
|
|||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { TopUpModal } from "./TopUpModal";
|
import { TopUpModal } from "./TopUpModal";
|
||||||
import { ChangePlanModal } from "./ChangePlanModal";
|
import { ChangePlanModal } from "./ChangePlanModal";
|
||||||
|
import { ReissueSimModal } from "./ReissueSimModal";
|
||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import {
|
|
||||||
canTopUpSim,
|
|
||||||
canReissueEsim,
|
|
||||||
canCancelSim,
|
|
||||||
type SimStatus,
|
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
|
|
||||||
interface SimActionsProps {
|
interface SimActionsProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
simType: "physical" | "esim";
|
simType: "physical" | "esim";
|
||||||
status: SimStatus;
|
status: string;
|
||||||
onTopUpSuccess?: () => void;
|
onTopUpSuccess?: () => void;
|
||||||
onPlanChangeSuccess?: () => void;
|
onPlanChangeSuccess?: () => void;
|
||||||
onCancelSuccess?: () => void;
|
onCancelSuccess?: () => void;
|
||||||
@ -45,7 +40,7 @@ export function SimActions({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||||
const [showCancelConfirm, setShowCancelConfirm] = 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 [loading, setLoading] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
@ -54,29 +49,17 @@ export function SimActions({
|
|||||||
"topup" | "reissue" | "cancel" | "changePlan" | null
|
"topup" | "reissue" | "cancel" | "changePlan" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const isActiveStatus = canTopUpSim(status);
|
const isActive = status === "active";
|
||||||
const canTopUp = isActiveStatus;
|
const canTopUp = isActive;
|
||||||
const canReissue = simType === "esim" && canReissueEsim(status);
|
const canReissue = isActive;
|
||||||
const canCancel = canCancelSim(status);
|
const canCancel = isActive;
|
||||||
|
|
||||||
const handleReissueEsim = async () => {
|
const reissueDisabledReason = useMemo(() => {
|
||||||
setLoading("reissue");
|
if (!isActive) {
|
||||||
setError(null);
|
return "SIM must be active to request a reissue.";
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
return null;
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
const handleCancelSim = async () => {
|
const handleCancelSim = async () => {
|
||||||
setLoading("cancel");
|
setLoading("cancel");
|
||||||
@ -85,6 +68,7 @@ export function SimActions({
|
|||||||
try {
|
try {
|
||||||
await apiClient.POST("/api/subscriptions/{id}/sim/cancel", {
|
await apiClient.POST("/api/subscriptions/{id}/sim/cancel", {
|
||||||
params: { path: { id: subscriptionId } },
|
params: { path: { id: subscriptionId } },
|
||||||
|
body: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
setSuccess("SIM service cancelled successfully");
|
setSuccess("SIM service cancelled successfully");
|
||||||
@ -112,32 +96,17 @@ export function SimActions({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="sim-actions"
|
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 */}
|
{/* Header */}
|
||||||
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
|
{!embedded && (
|
||||||
<div className="flex items-center">
|
<div className="px-6 py-6 border-b border-gray-200">
|
||||||
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
<h3 className="text-lg font-semibold tracking-tight text-slate-900 mb-1">
|
||||||
<svg
|
SIM Management Actions
|
||||||
className="h-6 w-6 text-blue-600"
|
</h3>
|
||||||
fill="none"
|
<p className="text-sm text-slate-600">Manage your SIM service</p>
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900">SIM Management Actions</h3>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">Manage your SIM service</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||||
@ -160,7 +129,7 @@ export function SimActions({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isActiveStatus && (
|
{!isActive && (
|
||||||
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
|
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
|
||||||
@ -172,7 +141,7 @@ export function SimActions({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className={`grid gap-4 ${embedded ? "grid-cols-1" : "grid-cols-2"}`}>
|
<div className="space-y-3">
|
||||||
{/* Top Up Data - Primary Action */}
|
{/* Top Up Data - Primary Action */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -184,70 +153,20 @@ export function SimActions({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!canTopUp || loading !== null}
|
disabled={!canTopUp || loading !== null}
|
||||||
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||||
canTopUp && loading === null
|
canTopUp && loading === null
|
||||||
? "text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
? "text-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-50 border-gray-200 cursor-not-allowed"
|
: "text-gray-400 bg-gray-100 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="bg-blue-100 rounded-lg p-1 mr-3">
|
<PlusIcon className="h-4 w-4 mr-3" />
|
||||||
<PlusIcon className="h-5 w-5 text-blue-600" />
|
<div className="text-left">
|
||||||
</div>
|
<div className="font-medium">
|
||||||
<span>{loading === "topup" ? "Processing..." : "Top Up Data"}</span>
|
{loading === "topup" ? "Processing..." : "Top Up Data"}
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Reissue eSIM (only for eSIMs) */}
|
|
||||||
{simType === "esim" && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setActiveInfo("reissue");
|
|
||||||
try {
|
|
||||||
router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
|
||||||
} catch {
|
|
||||||
setShowReissueConfirm(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!canReissue || loading !== null}
|
|
||||||
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
|
||||||
canReissue && loading === null
|
|
||||||
? "text-green-700 bg-green-50 border-green-200 hover:bg-green-100 hover:border-green-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
|
||||||
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="bg-green-100 rounded-lg p-1 mr-3">
|
|
||||||
<ArrowPathIcon className="h-5 w-5 text-green-600" />
|
|
||||||
</div>
|
</div>
|
||||||
<span>{loading === "reissue" ? "Processing..." : "Reissue eSIM"}</span>
|
<div className="text-xs opacity-90">Add more data to your plan</div>
|
||||||
</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -261,30 +180,81 @@ export function SimActions({
|
|||||||
setShowChangePlanModal(true);
|
setShowChangePlanModal(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={loading !== null}
|
disabled={!isActive || loading !== null}
|
||||||
className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${
|
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||||
loading === null
|
isActive && 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-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-50 border-gray-300 cursor-not-allowed"
|
: "text-gray-400 bg-gray-100 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="bg-purple-100 rounded-lg p-1 mr-3">
|
<ArrowPathIcon className="h-4 w-4 mr-3" />
|
||||||
<svg
|
<div className="text-left">
|
||||||
className="h-5 w-5 text-purple-600"
|
<div className="font-medium">
|
||||||
fill="none"
|
{loading === "change-plan" ? "Processing..." : "Change Plan"}
|
||||||
stroke="currentColor"
|
</div>
|
||||||
viewBox="0 0 24 24"
|
<div className="text-xs opacity-70">Switch to a different plan</div>
|
||||||
>
|
</div>
|
||||||
<path
|
</div>
|
||||||
strokeLinecap="round"
|
</button>
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
{/* Reissue SIM */}
|
||||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
<button
|
||||||
/>
|
onClick={() => {
|
||||||
</svg>
|
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>
|
</div>
|
||||||
<span>Change Plan</span>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -305,8 +275,7 @@ export function SimActions({
|
|||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
|
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this
|
<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.
|
||||||
if your previous download failed or you need to install on a new device.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -382,54 +351,24 @@ export function SimActions({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reissue eSIM Confirmation */}
|
{/* Reissue SIM Modal */}
|
||||||
{showReissueConfirm && (
|
{showReissueModal && (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<ReissueSimModal
|
||||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
subscriptionId={subscriptionId}
|
||||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
currentSimType={simType}
|
||||||
<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">
|
onClose={() => {
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
setShowReissueModal(false);
|
||||||
<div className="sm:flex sm:items-start">
|
setActiveInfo(null);
|
||||||
<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" />
|
onSuccess={() => {
|
||||||
</div>
|
setShowReissueModal(false);
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
setSuccess("SIM reissue request submitted successfully");
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
onReissueSuccess?.();
|
||||||
Reissue eSIM Profile
|
}}
|
||||||
</h3>
|
onError={message => {
|
||||||
<div className="mt-2">
|
setError(message);
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cancel Confirmation */}
|
{/* Cancel Confirmation */}
|
||||||
|
|||||||
@ -1,211 +1,430 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { formatSimPlanShort } from "@customer-portal/domain/sim";
|
|
||||||
import {
|
import {
|
||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
|
WifiIcon,
|
||||||
|
SignalIcon,
|
||||||
|
ClockIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
ClockIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
} 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 {
|
interface SimDetailsCardProps {
|
||||||
simDetails: SimDetails;
|
simDetails: SimDetails;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
embedded?: boolean;
|
embedded?: boolean; // when true, render content without card container
|
||||||
showFeaturesSummary?: boolean;
|
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({
|
export function SimDetailsCard({
|
||||||
simDetails,
|
simDetails,
|
||||||
isLoading = false,
|
isLoading,
|
||||||
error = null,
|
error,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
showFeaturesSummary = true,
|
showFeaturesSummary = true,
|
||||||
}: SimDetailsCardProps) {
|
}: 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) {
|
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) {
|
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";
|
// Modern eSIM details view with usage visualization
|
||||||
const statusIcon = STATUS_ICON_MAP[simDetails.status] ?? (
|
if (simDetails.simType === "esim") {
|
||||||
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
|
const remainingGB = simDetails.remainingQuotaMb / 1000;
|
||||||
);
|
const totalGB = 1048.6; // Mock total - should come from API
|
||||||
const statusClass = STATUS_BADGE_CLASS_MAP[simDetails.status] ?? "bg-gray-100 text-gray-800";
|
const usedGB = totalGB - remainingGB;
|
||||||
const containerClasses = embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100";
|
const usagePercentage = (usedGB / totalGB) * 100;
|
||||||
|
|
||||||
return (
|
// Usage Sparkline Component
|
||||||
<div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}>
|
const UsageSparkline = ({ data }: { data: Array<{ date: string; usedMB: number }> }) => {
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
const maxValue = Math.max(...data.map(d => d.usedMB), 1);
|
||||||
<div className="flex items-center gap-3">
|
const width = 80;
|
||||||
<div className="rounded-xl bg-blue-50 p-3">
|
const height = 16;
|
||||||
<DevicePhoneMobileIcon className="h-7 w-7 text-blue-600" />
|
|
||||||
</div>
|
const points = data.map((d, i) => {
|
||||||
<div>
|
const x = (i / (data.length - 1)) * width;
|
||||||
<h3 className="text-xl font-semibold text-gray-900">{planName}</h3>
|
const y = height - (d.usedMB / maxValue) * height;
|
||||||
<p className="text-sm text-gray-600">Account #{simDetails.account}</p>
|
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>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
{/* Content */}
|
||||||
<div className="space-y-4">
|
<div className={`${embedded ? "" : "px-6 py-4"}`}>
|
||||||
<section className="bg-gray-50 rounded-lg p-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
{/* SIM Information */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||||
SIM Information
|
SIM Information
|
||||||
</h4>
|
</h4>
|
||||||
<dl className="space-y-2 text-sm text-gray-700">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div>
|
||||||
<dt className="font-medium text-gray-600">Phone Number</dt>
|
<label className="text-xs text-gray-500">Phone Number</label>
|
||||||
<dd className="font-semibold text-gray-900">{simDetails.msisdn}</dd>
|
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="font-medium text-gray-600">SIM Type</dt>
|
{simDetails.simType === "physical" && (
|
||||||
<dd className="font-semibold text-gray-900">{simDetails.simType}</dd>
|
<div>
|
||||||
</div>
|
<label className="text-xs text-gray-500">ICCID</label>
|
||||||
<div className="flex justify-between">
|
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
|
||||||
<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>
|
|
||||||
</div>
|
</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">
|
{simDetails.eid && (
|
||||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
<div>
|
||||||
Data Remaining
|
<label className="text-xs text-gray-500">EID (eSIM)</label>
|
||||||
</h4>
|
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
|
||||||
<p className="text-2xl font-bold text-green-600">
|
</div>
|
||||||
{formatQuota(simDetails.remainingQuotaMb)}
|
)}
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">Remaining allowance in current cycle</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="bg-gray-50 rounded-lg p-4">
|
{simDetails.imsi && (
|
||||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
<div>
|
||||||
Activation Timeline
|
<label className="text-xs text-gray-500">IMSI</label>
|
||||||
</h4>
|
<p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
|
||||||
<dl className="space-y-2 text-sm text-gray-700">
|
</div>
|
||||||
<div className="flex justify-between">
|
)}
|
||||||
<dt className="font-medium text-gray-600">Activated</dt>
|
|
||||||
<dd>{formatDate(simDetails.activatedAt)}</dd>
|
{simDetails.startDate && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">Service Start Date</label>
|
||||||
|
<p className="text-sm text-gray-900">{formatDate(simDetails.startDate)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Features */}
|
||||||
|
{showFeaturesSummary && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
Service Features
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">Data Remaining</label>
|
||||||
|
<p className="text-lg font-semibold text-green-600">
|
||||||
|
{formatQuota(simDetails.remainingQuotaMb)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<SignalIcon
|
||||||
|
className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? "text-green-500" : "text-gray-400"}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${simDetails.hasVoice ? "text-green-600" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
Voice {simDetails.hasVoice ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DevicePhoneMobileIcon
|
||||||
|
className={`h-4 w-4 mr-1 ${simDetails.hasSms ? "text-green-500" : "text-gray-400"}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${simDetails.hasSms ? "text-green-600" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
SMS {simDetails.hasSms ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(simDetails.ipv4 || simDetails.ipv6) && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">IP Address</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{simDetails.ipv4 && (
|
||||||
|
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p>
|
||||||
|
)}
|
||||||
|
{simDetails.ipv6 && (
|
||||||
|
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
</div>
|
||||||
<dt className="font-medium text-gray-600">Expires</dt>
|
)}
|
||||||
<dd>{formatDate(simDetails.expiresAt)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showFeaturesSummary && (
|
{/* Pending Operations */}
|
||||||
<section className="space-y-3">
|
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && (
|
||||||
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
Service Features
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
Pending Operations
|
||||||
</h4>
|
</h4>
|
||||||
<FeatureToggleRow label="Voice Mail" enabled={simDetails.voiceMailEnabled} />
|
<div className="bg-blue-50 rounded-lg p-4">
|
||||||
<FeatureToggleRow label="Call Waiting" enabled={simDetails.callWaitingEnabled} />
|
{simDetails.pendingOperations.map((operation, index) => (
|
||||||
<FeatureToggleRow
|
<div key={index} className="flex items-center text-sm">
|
||||||
label="International Roaming"
|
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
|
||||||
enabled={simDetails.internationalRoamingEnabled}
|
<span className="text-blue-800">
|
||||||
/>
|
{operation.operation} scheduled for {formatDate(operation.scheduledDate)}
|
||||||
</section>
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { SimDetails };
|
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import {
|
|
||||||
buildSimFeaturesUpdatePayload,
|
|
||||||
type SimFeatureToggleSnapshot,
|
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
|
|
||||||
interface SimFeatureTogglesProps {
|
interface SimFeatureTogglesProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
@ -27,42 +23,37 @@ export function SimFeatureToggles({
|
|||||||
embedded = false,
|
embedded = false,
|
||||||
}: SimFeatureTogglesProps) {
|
}: SimFeatureTogglesProps) {
|
||||||
// Initial values
|
// Initial values
|
||||||
const initial = useMemo<SimFeatureToggleSnapshot>(
|
const initial = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
voiceMailEnabled: !!voiceMailEnabled,
|
vm: !!voiceMailEnabled,
|
||||||
callWaitingEnabled: !!callWaitingEnabled,
|
cw: !!callWaitingEnabled,
|
||||||
internationalRoamingEnabled: !!internationalRoamingEnabled,
|
ir: !!internationalRoamingEnabled,
|
||||||
networkType: networkType === "5G" ? "5G" : "4G",
|
nt: networkType === "5G" ? "5G" : "4G",
|
||||||
}),
|
}),
|
||||||
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
|
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Working values
|
// Working values
|
||||||
const [vm, setVm] = useState(initial.voiceMailEnabled);
|
const [vm, setVm] = useState(initial.vm);
|
||||||
const [cw, setCw] = useState(initial.callWaitingEnabled);
|
const [cw, setCw] = useState(initial.cw);
|
||||||
const [ir, setIr] = useState(initial.internationalRoamingEnabled);
|
const [ir, setIr] = useState(initial.ir);
|
||||||
const [nt, setNt] = useState<"4G" | "5G">(initial.networkType);
|
const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const successTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVm(initial.voiceMailEnabled);
|
setVm(initial.vm);
|
||||||
setCw(initial.callWaitingEnabled);
|
setCw(initial.cw);
|
||||||
setIr(initial.internationalRoamingEnabled);
|
setIr(initial.ir);
|
||||||
setNt(initial.networkType);
|
setNt(initial.nt as "4G" | "5G");
|
||||||
}, [initial]);
|
}, [initial.vm, initial.cw, initial.ir, initial.nt]);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
if (successTimerRef.current) {
|
setVm(initial.vm);
|
||||||
clearTimeout(successTimerRef.current);
|
setCw(initial.cw);
|
||||||
successTimerRef.current = null;
|
setIr(initial.ir);
|
||||||
}
|
setNt(initial.nt as "4G" | "5G");
|
||||||
setVm(initial.voiceMailEnabled);
|
|
||||||
setCw(initial.callWaitingEnabled);
|
|
||||||
setIr(initial.internationalRoamingEnabled);
|
|
||||||
setNt(initial.networkType);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
};
|
};
|
||||||
@ -72,21 +63,22 @@ export function SimFeatureToggles({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
try {
|
try {
|
||||||
const featurePayload = buildSimFeaturesUpdatePayload(initial, {
|
const featurePayload: {
|
||||||
voiceMailEnabled: vm,
|
voiceMailEnabled?: boolean;
|
||||||
callWaitingEnabled: cw,
|
callWaitingEnabled?: boolean;
|
||||||
internationalRoamingEnabled: ir,
|
internationalRoamingEnabled?: boolean;
|
||||||
networkType: nt,
|
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", {
|
await apiClient.POST("/api/subscriptions/{id}/sim/features", {
|
||||||
params: { path: { id: subscriptionId } },
|
params: { path: { id: subscriptionId } },
|
||||||
body: featurePayload,
|
body: featurePayload,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
setSuccess("No changes detected");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess("Changes submitted successfully");
|
setSuccess("Changes submitted successfully");
|
||||||
@ -95,224 +87,132 @@ export function SimFeatureToggles({
|
|||||||
setError(e instanceof Error ? e.message : "Failed to submit changes");
|
setError(e instanceof Error ? e.message : "Failed to submit changes");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (successTimerRef.current) {
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
clearTimeout(successTimerRef.current);
|
|
||||||
}
|
|
||||||
successTimerRef.current = window.setTimeout(() => {
|
|
||||||
setSuccess(null);
|
|
||||||
successTimerRef.current = null;
|
|
||||||
}, 3000);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (successTimerRef.current) {
|
|
||||||
clearTimeout(successTimerRef.current);
|
|
||||||
successTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Service Options */}
|
{/* Service Options */}
|
||||||
<div
|
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-100 shadow-md"}`}>
|
||||||
className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 overflow-hidden"}`}
|
<div className={`${embedded ? "" : "p-6"} space-y-4`}>
|
||||||
>
|
|
||||||
<div className={`${embedded ? "" : "p-6"} space-y-6`}>
|
|
||||||
{/* Voice Mail */}
|
{/* Voice Mail */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
<div className="flex items-center justify-between py-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="text-sm font-medium text-slate-900">Voice Mail</div>
|
||||||
<div className="bg-blue-100 rounded-lg p-2">
|
<div className="text-xs text-slate-500">¥300/month</div>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Call Waiting */}
|
{/* 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-1">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="text-sm font-medium text-slate-900">Call Waiting</div>
|
||||||
<div className="bg-purple-100 rounded-lg p-2">
|
<div className="text-xs text-slate-500">¥300/month</div>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* International Roaming */}
|
{/* 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-1">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="text-sm font-medium text-slate-900">International Roaming</div>
|
||||||
<div className="bg-green-100 rounded-lg p-2">
|
<div className="text-xs text-slate-500">Global connectivity</div>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Network Type */}
|
<div className="border-t border-gray-200 pt-6">
|
||||||
<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="mb-4">
|
||||||
<div className="flex-1">
|
<div className="text-sm font-medium text-slate-900 mb-1">Network Type</div>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="text-xs text-slate-500">Choose your preferred connectivity</div>
|
||||||
<div className="bg-orange-100 rounded-lg p-2">
|
<div className="text-xs text-red-600 mt-1">
|
||||||
<svg
|
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.
|
||||||
className="h-4 w-4 text-orange-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-semibold text-gray-900">Network Type</div>
|
|
||||||
<div className="text-xs text-gray-600">4G/5G connectivity</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex gap-4">
|
||||||
<div className="text-sm">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-gray-500">Current: </span>
|
<input
|
||||||
<span className="font-medium text-blue-600">{initial.networkType}</span>
|
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>
|
||||||
<div className="text-gray-400">→</div>
|
|
||||||
<select
|
|
||||||
value={nt}
|
|
||||||
onChange={e => setNt(e.target.value as "4G" | "5G")}
|
|
||||||
className="block w-20 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
|
|
||||||
>
|
|
||||||
<option value="4G">4G</option>
|
|
||||||
<option value="5G">5G</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">5G connectivity for enhanced speeds</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes and Actions */}
|
{/* Notes and Actions */}
|
||||||
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 p-6"}`}>
|
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 p-6"}`}>
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
<div className="flex items-start">
|
<h4 className="text-sm font-medium text-blue-900 mb-2 flex items-center gap-2">
|
||||||
<svg
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="h-5 w-5 text-yellow-600 mt-0.5 mr-3 flex-shrink-0"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="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"
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="space-y-2 text-sm text-yellow-800">
|
Important Notes
|
||||||
<p>
|
</h4>
|
||||||
<strong>Important Notes:</strong>
|
<ul className="text-xs text-blue-800 space-y-1">
|
||||||
</p>
|
<li className="flex items-start gap-2">
|
||||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||||
<li>Changes will take effect instantaneously (approx. 30min)</li>
|
Changes will take effect instantaneously (approx. 30min)
|
||||||
<li>May require smartphone/device restart after changes are applied</li>
|
</li>
|
||||||
<li>5G requires a compatible smartphone/device. Will not function on 4G devices</li>
|
<li className="flex items-start gap-2">
|
||||||
<li>
|
<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
|
May require smartphone device restart after changes are applied
|
||||||
month
|
</li>
|
||||||
</li>
|
<li className="flex items-start gap-2">
|
||||||
</ul>
|
<span className="w-1 h-1 bg-red-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||||
</div>
|
<span className="text-red-600">Voice, network, and plan changes must be requested at least 30 minutes apart.</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
|
|||||||
@ -1,84 +1,62 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
|
DocumentTextIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { SimDetailsCard } from "./SimDetailsCard";
|
import { type SimDetails } from "./SimDetailsCard";
|
||||||
import { DataUsageChart } from "./DataUsageChart";
|
|
||||||
import { SimActions } from "./SimActions";
|
|
||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import { SimFeatureToggles } from "./SimFeatureToggles";
|
import Link from "next/link";
|
||||||
import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
|
|
||||||
|
|
||||||
interface SimManagementSectionProps {
|
interface SimManagementSectionProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SimInfo {
|
||||||
|
details: SimDetails;
|
||||||
|
usage?: {
|
||||||
|
todayUsageMb: number;
|
||||||
|
recentDaysUsage: Array<{ date: string; usageMb: number }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
|
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
|
||||||
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
|
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const [activeTab, setActiveTab] = useState<"sim" | "invoices">("sim");
|
||||||
const isMountedRef = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
abortControllerRef.current?.abort();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchSimInfo = useCallback(async () => {
|
const fetchSimInfo = useCallback(async () => {
|
||||||
abortControllerRef.current?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
abortControllerRef.current = controller;
|
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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 } },
|
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");
|
throw new Error("Failed to load SIM information");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = simInfoSchema.parse(response.data);
|
|
||||||
|
|
||||||
if (controller.signal.aborted || !isMountedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSimInfo(payload);
|
setSimInfo(payload);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (controller.signal.aborted || !isMountedRef.current) {
|
const hasStatus = (v: unknown): v is { status: number } =>
|
||||||
return;
|
typeof v === "object" &&
|
||||||
}
|
v !== null &&
|
||||||
const hasStatus = (value: unknown): value is { status: number } =>
|
"status" in v &&
|
||||||
typeof value === "object" &&
|
typeof (v as { status: unknown }).status === "number";
|
||||||
value !== null &&
|
|
||||||
"status" in value &&
|
|
||||||
typeof (value as { status: unknown }).status === "number";
|
|
||||||
|
|
||||||
if (hasStatus(err) && err.status === 400) {
|
if (hasStatus(err) && err.status === 400) {
|
||||||
// Not a SIM subscription - this component shouldn't be shown
|
|
||||||
setError("This subscription is not a SIM service");
|
setError("This subscription is not a SIM service");
|
||||||
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 {
|
} finally {
|
||||||
if (!controller.signal.aborted && isMountedRef.current) {
|
setLoading(false);
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [subscriptionId]);
|
}, [subscriptionId]);
|
||||||
|
|
||||||
@ -87,35 +65,41 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
}, [fetchSimInfo]);
|
}, [fetchSimInfo]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
|
setLoading(true);
|
||||||
void fetchSimInfo();
|
void fetchSimInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleActionSuccess = () => {
|
const handleActionSuccess = () => {
|
||||||
// Refresh SIM info after any successful action
|
|
||||||
void fetchSimInfo();
|
void fetchSimInfo();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-8">
|
{/* Header */}
|
||||||
<div className="flex items-center mb-6">
|
<div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4">
|
||||||
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
<div className="flex items-center justify-between">
|
||||||
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />
|
<h1 className="text-white text-xl font-bold">Service Management</h1>
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
<div>
|
<div className="px-4 py-2 rounded-full border-2 border-white bg-white text-gray-900 text-sm font-medium">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2>
|
SIM Management
|
||||||
<p className="text-gray-600 mt-1">Loading your SIM service details...</p>
|
</div>
|
||||||
|
<div className="px-4 py-2 rounded-full border-2 border-white text-white text-sm font-medium">
|
||||||
|
Invoices
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="animate-pulse space-y-6">
|
</div>
|
||||||
<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>
|
{/* Loading Animation */}
|
||||||
<div className="h-48 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
|
<div className="p-5 space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="animate-pulse space-y-4">
|
||||||
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
|
<div className="h-24 bg-gray-200 rounded-2xl"></div>
|
||||||
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></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>
|
||||||
|
<div className="h-12 bg-gray-200 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -124,27 +108,19 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow-lg rounded-xl border border-red-100 p-8">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="flex items-center mb-6">
|
<div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4">
|
||||||
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
<h1 className="text-white text-xl font-bold">Service Management</h1>
|
||||||
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2>
|
|
||||||
<p className="text-gray-600 mt-1">Unable to load SIM information</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6">
|
||||||
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
|
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">Unable to Load SIM Information</h3>
|
||||||
Unable to Load SIM Information
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
|
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
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" />
|
<ArrowPathIcon className="h-5 w-5 mr-2" />
|
||||||
Retry
|
Retry
|
||||||
@ -158,106 +134,188 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actionSimType: "esim" | "physical" =
|
const remainingGB = simInfo.details.remainingQuotaMb / 1000;
|
||||||
simInfo.details.simType.toLowerCase() === "esim" ? "esim" : "physical";
|
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 (
|
return (
|
||||||
<div id="sim-management" className="space-y-8">
|
<div id="sim-management" className="min-h-screen bg-gray-50">
|
||||||
{/* SIM Details and Usage - Main Content */}
|
{/* 1. Top Header Bar */}
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
<div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4 shadow-lg">
|
||||||
{/* Main Content Area - Actions and Settings (Left Side) */}
|
<div className="flex items-center justify-between">
|
||||||
<div className="order-2 xl:col-span-2 xl:order-1">
|
<h1 className="text-white text-[22px] font-bold">Service Management</h1>
|
||||||
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
|
<div className="flex gap-2">
|
||||||
<SimActions
|
<button
|
||||||
subscriptionId={subscriptionId}
|
onClick={() => setActiveTab("sim")}
|
||||||
simType={actionSimType}
|
className={`px-4 py-2 rounded-full border-2 border-white text-sm font-medium transition-all ${
|
||||||
status={simInfo.details.status}
|
activeTab === "sim"
|
||||||
currentPlanCode={simInfo.details.planCode}
|
? "bg-white text-gray-900"
|
||||||
onTopUpSuccess={handleActionSuccess}
|
: "bg-transparent text-white"
|
||||||
onPlanChangeSuccess={handleActionSuccess}
|
}`}
|
||||||
onCancelSuccess={handleActionSuccess}
|
>
|
||||||
onReissueSuccess={handleActionSuccess}
|
SIM Management
|
||||||
embedded={true}
|
</button>
|
||||||
/>
|
<Link
|
||||||
<div className="mt-6">
|
href={`/subscriptions/${subscriptionId}`}
|
||||||
<p className="text-sm text-gray-600 font-medium mb-3">Modify service options</p>
|
className="px-4 py-2 rounded-full border-2 border-white text-white text-sm font-medium"
|
||||||
<SimFeatureToggles
|
>
|
||||||
subscriptionId={subscriptionId}
|
Invoices
|
||||||
voiceMailEnabled={simInfo.details.voiceMailEnabled}
|
</Link>
|
||||||
callWaitingEnabled={simInfo.details.callWaitingEnabled}
|
</div>
|
||||||
internationalRoamingEnabled={simInfo.details.internationalRoamingEnabled}
|
</div>
|
||||||
networkType={simInfo.details.networkType}
|
</div>
|
||||||
onChanged={handleActionSuccess}
|
|
||||||
embedded
|
{/* 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar - Compact Info (Right Side) */}
|
{/* 3. Invoice & Data Usage Row */}
|
||||||
<div className="order-1 xl:order-2 space-y-8">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Details + Usage combined card for mobile-first */}
|
{/* Left Column - Invoice Card */}
|
||||||
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6 space-y-6">
|
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl p-5 flex flex-col justify-between">
|
||||||
<SimDetailsCard
|
<div>
|
||||||
simDetails={simInfo.details}
|
<p className="text-sm text-gray-600 mb-2">Latest Invoice</p>
|
||||||
isLoading={false}
|
<p className="text-3xl font-bold text-[#2F80ED] mb-4">3400 ¥</p>
|
||||||
error={null}
|
|
||||||
embedded={true}
|
|
||||||
showFeaturesSummary={false}
|
|
||||||
/>
|
|
||||||
<DataUsageChart
|
|
||||||
usage={simInfo.usage}
|
|
||||||
remainingQuotaMb={simInfo.details.remainingQuotaMb}
|
|
||||||
isLoading={false}
|
|
||||||
error={null}
|
|
||||||
embedded={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Important Information Card */}
|
|
||||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
|
|
||||||
<div className="flex items-center mb-4">
|
|
||||||
<div className="bg-blue-200 rounded-lg p-2 mr-3">
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-blue-900">Important Information</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-2 text-sm text-blue-800">
|
<button className="w-full bg-[#2F80ED] text-white font-semibold py-3 rounded-full hover:bg-[#2671d9] transition-colors">
|
||||||
<li className="flex items-start">
|
PAY
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
</button>
|
||||||
Data usage is updated in real-time and may take a few minutes to reflect recent
|
|
||||||
activity
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
|
||||||
Top-up data will be available immediately after successful processing
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
|
||||||
SIM cancellation is permanent and cannot be undone
|
|
||||||
</li>
|
|
||||||
{simInfo.details.simType === "esim" && (
|
|
||||||
<li className="flex items-start">
|
|
||||||
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
|
|
||||||
eSIM profile reissue will provide a new QR code for activation
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,13 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import { useSimTopUpPricing } from "../hooks/useSimTopUpPricing";
|
|
||||||
import {
|
|
||||||
simTopUpRequestSchema,
|
|
||||||
type SimTopUpPricingPreviewResponse,
|
|
||||||
} from "@customer-portal/domain/sim";
|
|
||||||
|
|
||||||
interface TopUpModalProps {
|
interface TopUpModalProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
@ -19,22 +14,6 @@ interface TopUpModalProps {
|
|||||||
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
||||||
const [gbAmount, setGbAmount] = useState<string>("1");
|
const [gbAmount, setGbAmount] = useState<string>("1");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { 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 getCurrentAmountMb = () => {
|
||||||
const gb = parseInt(gbAmount, 10);
|
const gb = parseInt(gbAmount, 10);
|
||||||
@ -42,39 +21,35 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isValidAmount = () => {
|
const isValidAmount = () => {
|
||||||
if (!pricing) return false;
|
const gb = Number(gbAmount);
|
||||||
const mb = getCurrentAmountMb();
|
return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB, whole numbers only (Freebit API limit)
|
||||||
return mb >= pricing.minQuotaMb && mb <= pricing.maxQuotaMb;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayCost = preview?.totalPriceJpy ?? 0;
|
const calculateCost = () => {
|
||||||
const pricePerGb = pricing?.pricePerGbJpy ?? 500;
|
const gb = parseInt(gbAmount, 10);
|
||||||
|
return isNaN(gb) ? 0 : gb * 500; // 1GB = 500 JPY
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!isValidAmount()) {
|
if (!isValidAmount()) {
|
||||||
onError(
|
onError("Please enter a whole number between 1 GB and 100 GB");
|
||||||
`Please enter a valid amount between ${pricing ? pricing.minQuotaMb / 1000 : 1} GB and ${pricing ? pricing.maxQuotaMb / 1000 : 50} GB`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validationResult = simTopUpRequestSchema.safeParse({
|
const requestBody = {
|
||||||
quotaMb: getCurrentAmountMb(),
|
quotaMb: getCurrentAmountMb(),
|
||||||
});
|
amount: calculateCost(),
|
||||||
|
currency: "JPY",
|
||||||
if (!validationResult.success) {
|
};
|
||||||
onError(validationResult.error.issues[0]?.message ?? "Invalid top-up amount");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await apiClient.POST("/api/subscriptions/{id}/sim/top-up", {
|
await apiClient.POST("/api/subscriptions/{id}/sim/top-up", {
|
||||||
params: { path: { id: subscriptionId } },
|
params: { path: { id: subscriptionId } },
|
||||||
body: validationResult.data,
|
body: requestBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
@ -137,9 +112,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Enter the amount of data you want to add (
|
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
|
||||||
{pricing ? `${pricing.minQuotaMb / 1000} - ${pricing.maxQuotaMb / 1000}` : "1 - 50"}{" "}
|
|
||||||
GB, whole numbers)
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -154,21 +127,20 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-lg font-bold text-blue-900">
|
<div className="text-lg font-bold text-blue-900">
|
||||||
¥{displayCost.toLocaleString()}
|
¥{calculateCost().toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-blue-700">(1GB = ¥{pricePerGb})</div>
|
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Validation Warning */}
|
{/* Validation Warning */}
|
||||||
{!isValidAmount() && gbAmount && pricing && (
|
{!isValidAmount() && gbAmount && (
|
||||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" />
|
<ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" />
|
||||||
<p className="text-sm text-red-800">
|
<p className="text-sm text-red-800">
|
||||||
Amount must be between {pricing.minQuotaMb / 1000} GB and{" "}
|
Amount must be a whole number between 1 GB and 50 GB
|
||||||
{pricing.maxQuotaMb / 1000} GB
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -186,14 +158,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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
|
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||||
? "Processing..."
|
|
||||||
: pricingLoading
|
|
||||||
? "Loading..."
|
|
||||||
: `Top Up Now - ¥${displayCost.toLocaleString()}`}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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