diff --git a/ASI_N6_PASI_20251229.csv b/ASI_N6_PASI_20251229.csv new file mode 100644 index 00000000..6a032ecd --- /dev/null +++ b/ASI_N6_PASI_20251229.csv @@ -0,0 +1,50 @@ +1,02000002470001,PT0220024700010,PASI,20251229,,,, +2,02000002470002,PT0220024700020,PASI,20251229,,,, +3,02000002470003,PT0220024700030,PASI,20251229,,,, +4,02000002470004,PT0220024700040,PASI,20251229,,,, +5,02000002470005,PT0220024700050,PASI,20251229,,,, +6,02000002470006,PT0220024700060,PASI,20251229,,,, +7,02000002470007,PT0220024700070,PASI,20251229,,,, +8,02000002470008,PT0220024700080,PASI,20251229,,,, +9,02000002470009,PT0220024700090,PASI,20251229,,,, +10,02000002470010,PT0220024700100,PASI,20251229,,,, +11,02000002470011,PT0220024700110,PASI,20251229,,,, +12,02000002470012,PT0220024700120,PASI,20251229,,,, +13,02000002470013,PT0220024700130,PASI,20251229,,,, +14,02000002470014,PT0220024700140,PASI,20251229,,,, +15,02000002470015,PT0220024700150,PASI,20251229,,,, +16,02000002470016,PT0220024700160,PASI,20251229,,,, +17,02000002470017,PT0220024700170,PASI,20251229,,,, +18,02000002470018,PT0220024700180,PASI,20251229,,,, +19,02000002470019,PT0220024700190,PASI,20251229,,,, +20,02000002470020,PT0220024700200,PASI,20251229,,,, +21,02000002470021,PT0220024700210,PASI,20251229,,,, +22,02000002470022,PT0220024700220,PASI,20251229,,,, +23,02000002470023,PT0220024700230,PASI,20251229,,,, +24,02000002470024,PT0220024700240,PASI,20251229,,,, +25,02000002470025,PT0220024700250,PASI,20251229,,,, +26,02000002470026,PT0220024700260,PASI,20251229,,,, +27,02000002470027,PT0220024700270,PASI,20251229,,,, +28,02000002470028,PT0220024700280,PASI,20251229,,,, +29,02000002470029,PT0220024700290,PASI,20251229,,,, +30,02000002470030,PT0220024700300,PASI,20251229,,,, +31,02000002470031,PT0220024700310,PASI,20251229,,,, +32,02000002470032,PT0220024700320,PASI,20251229,,,, +33,02000002470033,PT0220024700330,PASI,20251229,,,, +34,02000002470034,PT0220024700340,PASI,20251229,,,, +35,02000002470035,PT0220024700350,PASI,20251229,,,, +36,02000002470036,PT0220024700360,PASI,20251229,,,, +37,02000002470037,PT0220024700370,PASI,20251229,,,, +38,02000002470038,PT0220024700380,PASI,20251229,,,, +39,02000002470039,PT0220024700390,PASI,20251229,,,, +40,02000002470040,PT0220024700400,PASI,20251229,,,, +41,02000002470041,PT0220024700410,PASI,20251229,,,, +42,02000002470042,PT0220024700420,PASI,20251229,,,, +43,02000002470043,PT0220024700430,PASI,20251229,,,, +44,02000002470044,PT0220024700440,PASI,20251229,,,, +45,02000002470045,PT0220024700450,PASI,20251229,,,, +46,02000002470046,PT0220024700460,PASI,20251229,,,, +47,02000002470047,PT0220024700470,PASI,20251229,,,, +48,02000002470048,PT0220024700480,PASI,20251229,,,, +49,02000002470049,PT0220024700490,PASI,20251229,,,, +50,02000002470050,PT0220024700500,PASI,20251229,,,, diff --git a/ASI_N7_PASI_20251229.csv b/ASI_N7_PASI_20251229.csv new file mode 100644 index 00000000..2357f15f --- /dev/null +++ b/ASI_N7_PASI_20251229.csv @@ -0,0 +1,50 @@ +51,07000240001,PT0270002400010,PASI,20251229,,,, +52,07000240002,PT0270002400020,PASI,20251229,,,, +53,07000240003,PT0270002400030,PASI,20251229,,,, +54,07000240004,PT0270002400040,PASI,20251229,,,, +55,07000240005,PT0270002400050,PASI,20251229,,,, +56,07000240006,PT0270002400060,PASI,20251229,,,, +57,07000240007,PT0270002400070,PASI,20251229,,,, +58,07000240008,PT0270002400080,PASI,20251229,,,, +59,07000240009,PT0270002400090,PASI,20251229,,,, +60,07000240010,PT0270002400100,PASI,20251229,,,, +61,07000240011,PT0270002400110,PASI,20251229,,,, +62,07000240012,PT0270002400120,PASI,20251229,,,, +63,07000240013,PT0270002400130,PASI,20251229,,,, +64,07000240014,PT0270002400140,PASI,20251229,,,, +65,07000240015,PT0270002400150,PASI,20251229,,,, +66,07000240016,PT0270002400160,PASI,20251229,,,, +67,07000240017,PT0270002400170,PASI,20251229,,,, +68,07000240018,PT0270002400180,PASI,20251229,,,, +69,07000240019,PT0270002400190,PASI,20251229,,,, +70,07000240020,PT0270002400200,PASI,20251229,,,, +71,07000240021,PT0270002400210,PASI,20251229,,,, +72,07000240022,PT0270002400220,PASI,20251229,,,, +73,07000240023,PT0270002400230,PASI,20251229,,,, +74,07000240024,PT0270002400240,PASI,20251229,,,, +75,07000240025,PT0270002400250,PASI,20251229,,,, +76,07000240026,PT0270002400260,PASI,20251229,,,, +77,07000240027,PT0270002400270,PASI,20251229,,,, +78,07000240028,PT0270002400280,PASI,20251229,,,, +79,07000240029,PT0270002400290,PASI,20251229,,,, +80,07000240030,PT0270002400300,PASI,20251229,,,, +81,07000240031,PT0270002400310,PASI,20251229,,,, +82,07000240032,PT0270002400320,PASI,20251229,,,, +83,07000240033,PT0270002400330,PASI,20251229,,,, +84,07000240034,PT0270002400340,PASI,20251229,,,, +85,07000240035,PT0270002400350,PASI,20251229,,,, +86,07000240036,PT0270002400360,PASI,20251229,,,, +87,07000240037,PT0270002400370,PASI,20251229,,,, +88,07000240038,PT0270002400380,PASI,20251229,,,, +89,07000240039,PT0270002400390,PASI,20251229,,,, +90,07000240040,PT0270002400400,PASI,20251229,,,, +91,07000240041,PT0270002400410,PASI,20251229,,,, +92,07000240042,PT0270002400420,PASI,20251229,,,, +93,07000240043,PT0270002400430,PASI,20251229,,,, +94,07000240044,PT0270002400440,PASI,20251229,,,, +95,07000240045,PT0270002400450,PASI,20251229,,,, +96,07000240046,PT0270002400460,PASI,20251229,,,, +97,07000240047,PT0270002400470,PASI,20251229,,,, +98,07000240048,PT0270002400480,PASI,20251229,,,, +99,07000240049,PT0270002400490,PASI,20251229,,,, +100,07000240050,PT0270002400500,PASI,20251229,,,, diff --git a/apps/bff/sim-api-test-log.csv b/apps/bff/sim-api-test-log.csv new file mode 100644 index 00000000..2fa55d66 --- /dev/null +++ b/apps/bff/sim-api-test-log.csv @@ -0,0 +1 @@ +Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Response Status,Error,Additional Info diff --git a/apps/bff/src/integrations/freebit/freebit.module.ts b/apps/bff/src/integrations/freebit/freebit.module.ts index 5592ae08..680082a8 100644 --- a/apps/bff/src/integrations/freebit/freebit.module.ts +++ b/apps/bff/src/integrations/freebit/freebit.module.ts @@ -11,6 +11,7 @@ import { FreebitPlanService } from "./services/freebit-plan.service.js"; import { FreebitVoiceService } from "./services/freebit-voice.service.js"; import { FreebitCancellationService } from "./services/freebit-cancellation.service.js"; import { FreebitEsimService } from "./services/freebit-esim.service.js"; +import { FreebitTestTrackerService } from "./services/freebit-test-tracker.service.js"; import { SimManagementModule } from "../../modules/subscriptions/sim-management/sim-management.module.js"; @Module({ @@ -21,6 +22,7 @@ import { SimManagementModule } from "../../modules/subscriptions/sim-management/ FreebitAuthService, FreebitMapperService, FreebitRateLimiterService, + FreebitTestTrackerService, // Specialized operation services FreebitAccountService, FreebitUsageService, diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index f8abfa99..878048ca 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -5,6 +5,7 @@ import { RetryableErrors, withRetry } from "@bff/core/utils/retry.util.js"; import { redactForLogs } from "@bff/core/logging/redaction.util.js"; import { FreebitAuthService } from "./freebit-auth.service.js"; import { FreebitError } from "./freebit-error.service.js"; +import { FreebitTestTrackerService } from "./freebit-test-tracker.service.js"; interface FreebitResponseBase { resultCode?: string | number; @@ -18,6 +19,7 @@ interface FreebitResponseBase { export class FreebitClientService { constructor( private readonly authService: FreebitAuthService, + private readonly testTracker: FreebitTestTrackerService, @Inject(Logger) private readonly logger: Logger ) {} @@ -35,91 +37,112 @@ export class FreebitClientService { const requestPayload = { ...payload, authKey }; let attempt = 0; - return withRetry( - async () => { - attempt += 1; + try { + const responseData = await withRetry( + async () => { + attempt += 1; - this.logger.debug(`Freebit API request`, { - url, - attempt, - maxAttempts: config.retryAttempts, - payload: redactForLogs(requestPayload), - }); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.timeout); - try { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: `json=${JSON.stringify(requestPayload)}`, - signal: controller.signal, + this.logger.debug(`Freebit API request`, { + url, + attempt, + maxAttempts: config.retryAttempts, + payload: redactForLogs(requestPayload), }); - if (!response.ok) { - const isProd = process.env.NODE_ENV === "production"; - const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); - this.logger.error("Freebit API HTTP error", { - url, - status: response.status, - statusText: response.statusText, - ...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}), - attempt, - }); - throw new FreebitError( - `HTTP ${response.status}: ${response.statusText}`, - response.status.toString() - ); - } - - const responseData = (await response.json()) as TResponse; - - const resultCode = this.normalizeResultCode(responseData.resultCode); - const statusCode = this.normalizeResultCode(responseData.status?.statusCode); - - if (resultCode && resultCode !== "100") { - const isProd = process.env.NODE_ENV === "production"; - this.logger.warn("Freebit API returned error response", { - url, - resultCode, - statusCode, - statusMessage: responseData.status?.message, - ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `json=${JSON.stringify(requestPayload)}`, + signal: controller.signal, }); - throw new FreebitError( - `API Error: ${responseData.status?.message || "Unknown error"}`, - resultCode, - statusCode, - responseData.status?.message - ); - } - - this.logger.debug("Freebit API request successful", { url, resultCode }); - return responseData; - } finally { - clearTimeout(timeoutId); - } - }, - { - maxAttempts: config.retryAttempts, - baseDelayMs: 1000, - maxDelayMs: 10000, - isRetryable: error => { - if (error instanceof FreebitError) { - if (error.isAuthError() && attempt === 1) { - this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url }); - this.authService.clearAuthCache(); - return true; + if (!response.ok) { + const isProd = process.env.NODE_ENV === "production"; + const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); + this.logger.error("Freebit API HTTP error", { + url, + status: response.status, + statusText: response.statusText, + ...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}), + attempt, + }); + throw new FreebitError( + `HTTP ${response.status}: ${response.statusText}`, + response.status.toString() + ); } - return error.isRetryable(); + + const responseData = (await response.json()) as TResponse; + + const resultCode = this.normalizeResultCode(responseData.resultCode); + const statusCode = this.normalizeResultCode(responseData.status?.statusCode); + + if (resultCode && resultCode !== "100") { + const isProd = process.env.NODE_ENV === "production"; + this.logger.warn("Freebit API returned error response", { + url, + resultCode, + statusCode, + statusMessage: responseData.status?.message, + ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), + }); + + throw new FreebitError( + `API Error: ${responseData.status?.message || "Unknown error"}`, + resultCode, + statusCode, + responseData.status?.message + ); + } + + this.logger.debug("Freebit API request successful", { url, resultCode }); + return responseData; + } finally { + clearTimeout(timeoutId); } - return RetryableErrors.isTransientError(error); }, - logger: this.logger, - logContext: "Freebit API request", - } - ); + { + maxAttempts: config.retryAttempts, + baseDelayMs: 1000, + maxDelayMs: 10000, + isRetryable: error => { + if (error instanceof FreebitError) { + if (error.isAuthError() && attempt === 1) { + this.logger.warn("Freebit auth error detected, clearing cache and retrying", { + url, + }); + this.authService.clearAuthCache(); + return true; + } + return error.isRetryable(); + } + return RetryableErrors.isTransientError(error); + }, + logger: this.logger, + logContext: "Freebit API request", + } + ); + + // Track successful API call + this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => { + this.logger.debug("Failed to track API call", { + error: error instanceof Error ? error.message : String(error), + }); + }); + + return responseData; + } catch (error: unknown) { + // Track failed API call + this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => { + this.logger.debug("Failed to track API call error", { + error: trackError instanceof Error ? trackError.message : String(trackError), + }); + }); + throw error; + } } /** @@ -133,81 +156,102 @@ export class FreebitClientService { const url = this.buildUrl(config.baseUrl, endpoint); let attempt = 0; - return withRetry( - async () => { - attempt += 1; - this.logger.debug("Freebit JSON API request", { - url, - attempt, - maxAttempts: config.retryAttempts, - payload: redactForLogs(payload), - }); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), config.timeout); - try { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - signal: controller.signal, + try { + const responseData = await withRetry( + async () => { + attempt += 1; + this.logger.debug("Freebit JSON API request", { + url, + attempt, + maxAttempts: config.retryAttempts, + payload: redactForLogs(payload), }); - if (!response.ok) { - throw new FreebitError( - `HTTP ${response.status}: ${response.statusText}`, - response.status.toString() - ); - } - - const responseData = (await response.json()) as TResponse; - - const resultCode = this.normalizeResultCode(responseData.resultCode); - const statusCode = this.normalizeResultCode(responseData.status?.statusCode); - - if (resultCode && resultCode !== "100") { - const isProd = process.env.NODE_ENV === "production"; - this.logger.error("Freebit API returned error result code", { - url, - resultCode, - statusCode, - message: responseData.status?.message, - ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), - attempt, + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, }); - throw new FreebitError( - `API Error: ${responseData.status?.message || "Unknown error"}`, - resultCode, - statusCode, - responseData.status?.message - ); - } - this.logger.debug("Freebit JSON API request successful", { url, resultCode }); - return responseData; - } finally { - clearTimeout(timeoutId); - } - }, - { - maxAttempts: config.retryAttempts, - baseDelayMs: 1000, - maxDelayMs: 10000, - isRetryable: error => { - if (error instanceof FreebitError) { - if (error.isAuthError() && attempt === 1) { - this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url }); - this.authService.clearAuthCache(); - return true; + if (!response.ok) { + throw new FreebitError( + `HTTP ${response.status}: ${response.statusText}`, + response.status.toString() + ); } - return error.isRetryable(); + + const responseData = (await response.json()) as TResponse; + + const resultCode = this.normalizeResultCode(responseData.resultCode); + const statusCode = this.normalizeResultCode(responseData.status?.statusCode); + + if (resultCode && resultCode !== "100") { + const isProd = process.env.NODE_ENV === "production"; + this.logger.error("Freebit API returned error result code", { + url, + resultCode, + statusCode, + message: responseData.status?.message, + ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), + attempt, + }); + throw new FreebitError( + `API Error: ${responseData.status?.message || "Unknown error"}`, + resultCode, + statusCode, + responseData.status?.message + ); + } + + this.logger.debug("Freebit JSON API request successful", { url, resultCode }); + return responseData; + } finally { + clearTimeout(timeoutId); } - return RetryableErrors.isTransientError(error); }, - logger: this.logger, - logContext: "Freebit JSON API request", - } - ); + { + maxAttempts: config.retryAttempts, + baseDelayMs: 1000, + maxDelayMs: 10000, + isRetryable: error => { + if (error instanceof FreebitError) { + if (error.isAuthError() && attempt === 1) { + this.logger.warn("Freebit auth error detected, clearing cache and retrying", { + url, + }); + this.authService.clearAuthCache(); + return true; + } + return error.isRetryable(); + } + return RetryableErrors.isTransientError(error); + }, + logger: this.logger, + logContext: "Freebit JSON API request", + } + ); + + // Track successful API call + this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => { + this.logger.debug("Failed to track API call", { + error: error instanceof Error ? error.message : String(error), + }); + }); + + return responseData; + } catch (error: unknown) { + // Track failed API call + this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => { + this.logger.debug("Failed to track API call error", { + error: trackError instanceof Error ? trackError.message : String(trackError), + }); + }); + throw error; + } } /** @@ -264,4 +308,52 @@ export class FreebitClientService { const normalized = String(code).trim(); return normalized.length > 0 ? normalized : undefined; } + + /** + * Track API call for testing purposes + */ + private async trackApiCall( + endpoint: string, + payload: unknown, + response: FreebitResponseBase | null, + error: unknown + ): Promise { + const payloadObj = payload as Record; + const phoneNumber = this.testTracker.extractPhoneNumber( + (payloadObj.account as string) || "", + payload + ); + + // Only track if we have a phone number (SIM-related calls) + if (!phoneNumber) { + return; + } + + const timestamp = this.testTracker.getCurrentTimestamp(); + const resultCode = response?.resultCode + ? String(response.resultCode) + : error instanceof FreebitError + ? String(error.resultCode || "ERROR") + : "ERROR"; + + const statusMessage = + response?.status?.message || + (error instanceof FreebitError + ? error.message + : error + ? extractErrorMessage(error) + : "Success"); + + await this.testTracker.logApiCall({ + timestamp, + apiEndpoint: endpoint, + apiMethod: "POST", + phoneNumber, + simIdentifier: (payloadObj.account as string) || phoneNumber, + requestPayload: JSON.stringify(redactForLogs(payload)), + responseStatus: resultCode === "100" ? "Success" : `Error: ${resultCode}`, + error: error ? extractErrorMessage(error) : undefined, + additionalInfo: statusMessage, + }); + } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts b/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts new file mode 100644 index 00000000..a8ca2448 --- /dev/null +++ b/apps/bff/src/integrations/freebit/services/freebit-test-tracker.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { promises as fs } from "fs"; +import { join } from "path"; + +export interface TestTrackingRecord { + timestamp: string; + apiEndpoint: string; + apiMethod: string; + phoneNumber: string; + simIdentifier?: string; + requestPayload?: string; + responseStatus?: string; + error?: string; + additionalInfo?: string; +} + +/** + * Service for tracking physical SIM test API calls + * Logs all API calls to a CSV file for testing purposes + */ +@Injectable() +export class FreebitTestTrackerService { + private readonly logFilePath: string; + private readonly csvHeaders = [ + "Timestamp", + "API Endpoint", + "API Method", + "Phone Number", + "SIM Identifier", + "Request Payload", + "Response Status", + "Error", + "Additional Info", + ]; + + constructor(@Inject(Logger) private readonly logger: Logger) { + // Store log file in project root + const projectRoot = process.cwd(); + this.logFilePath = join(projectRoot, "sim-api-test-log.csv"); + + // Initialize CSV file with headers if it doesn't exist + this.initializeLogFile().catch((error: unknown) => { + this.logger.error("Failed to initialize test tracking log file", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + /** + * Initialize the CSV log file with headers if it doesn't exist + */ + private async initializeLogFile(): Promise { + try { + await fs.access(this.logFilePath); + // File exists, no need to write headers + } catch { + // File doesn't exist, create it with headers + const headers = this.csvHeaders.join(",") + "\n"; + await fs.writeFile(this.logFilePath, headers, "utf-8"); + this.logger.log("Created test tracking log file", { path: this.logFilePath }); + } + } + + /** + * Log an API call to the CSV file + */ + async logApiCall(record: TestTrackingRecord): Promise { + try { + await this.initializeLogFile(); + + // Format the record as CSV row + const row = + [ + record.timestamp, + this.escapeCsvField(record.apiEndpoint), + this.escapeCsvField(record.apiMethod), + this.escapeCsvField(record.phoneNumber), + this.escapeCsvField(record.simIdentifier || ""), + this.escapeCsvField(record.requestPayload || ""), + this.escapeCsvField(record.responseStatus || ""), + this.escapeCsvField(record.error || ""), + this.escapeCsvField(record.additionalInfo || ""), + ].join(",") + "\n"; + + // Append to file + await fs.appendFile(this.logFilePath, row, "utf-8"); + + this.logger.debug("Logged API call to test tracking file", { + endpoint: record.apiEndpoint, + phoneNumber: record.phoneNumber, + }); + } catch (error) { + // Don't throw - logging failures shouldn't break the API + this.logger.error("Failed to log API call to test tracking file", { + error: error instanceof Error ? error.message : String(error), + record, + }); + } + } + + /** + * Escape CSV field values (handle commas, quotes, newlines) + */ + private escapeCsvField(field: string): string { + if (!field) return ""; + + // If field contains comma, quote, or newline, wrap in quotes and escape quotes + if (field.includes(",") || field.includes('"') || field.includes("\n")) { + return `"${field.replace(/"/g, '""')}"`; + } + + return field; + } + + /** + * Extract phone number from account or payload + */ + extractPhoneNumber(account: string, payload?: unknown): string { + if (account) return account; + + // Try to extract from payload if it's an object + if (payload && typeof payload === "object") { + const obj = payload as Record; + return (obj.account as string) || (obj.msisdn as string) || (obj.phoneNumber as string) || ""; + } + + return ""; + } + + /** + * Format timestamp as ISO string + */ + getCurrentTimestamp(): string { + return new Date().toISOString(); + } +} diff --git a/apps/portal/src/features/services/components/internet/HowItWorksSection.tsx b/apps/portal/src/features/services/components/internet/HowItWorksSection.tsx index c187e162..5a37291f 100644 --- a/apps/portal/src/features/services/components/internet/HowItWorksSection.tsx +++ b/apps/portal/src/features/services/components/internet/HowItWorksSection.tsx @@ -1,44 +1,31 @@ "use client"; -import { - UserPlusIcon, - MagnifyingGlassIcon, - CheckBadgeIcon, - RocketLaunchIcon, -} from "@heroicons/react/24/outline"; +import { MapPin, Settings, Calendar, Wifi } from "lucide-react"; interface StepProps { number: number; icon: React.ReactNode; title: string; description: string; - isLast?: boolean; } -function Step({ number, icon, title, description, isLast = false }: StepProps) { +function Step({ number, icon, title, description }: StepProps) { return ( -
- {/* Step number with icon */} -
-
+
+ {/* Icon with number badge */} +
+
{icon}
- {/* Connector line */} - {!isLast && ( -
- )} + {/* Number badge */} +
+ {number} +
{/* Content */} -
-
- - Step {number} - -
-

{title}

-

{description}

-
+

{title}

+

{description}

); } @@ -46,51 +33,68 @@ function Step({ number, icon, title, description, isLast = false }: StepProps) { export function HowItWorksSection() { const steps = [ { - icon: , - title: "Create your account", - description: - "Sign up with your email and provide your service address. This only takes a minute.", + icon: , + title: "Enter Address", + description: "Submit your address for coverage check", }, { - icon: , - title: "We verify with NTT", - description: - "Our team checks what service is available at your address. This takes 1-2 business days.", + icon: , + title: "We Verify", + description: "Our team checks with NTT (1-2 days)", }, { - icon: , - title: "Choose your plan", - description: - "Once verified, you'll see exactly which plans are available and can select your tier (Silver, Gold, or Platinum).", + icon: , + title: "Sign Up & Order", + description: "Create account and select your plan", }, { - icon: , - title: "Get connected", - description: - "We coordinate NTT installation and set up your service. You'll be online in no time.", + icon: , + title: "Get Connected", + description: "NTT installs fiber at your home", }, ]; return ( -
-
-

How it works

-

- Getting connected is simple. Here's what to expect. -

+
+ {/* Header */} +
+ + Getting Started + +

How It Works

-
- {steps.map((step, index) => ( - + {/* Connecting line - hidden on mobile */} +
+ + {/* Curved path SVG for visual connection - hidden on mobile */} + + - ))} + + + {/* Steps grid */} +
+ {steps.map((step, index) => ( + + ))} +
); diff --git a/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx b/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx index fef06b62..90935f49 100644 --- a/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx +++ b/apps/portal/src/features/services/components/internet/PublicOfferingCard.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { ChevronDown, ChevronUp, Home, Building2, Zap, Info, X } from "lucide-react"; +import { ChevronDown, ChevronUp, Home, Building2, Info, X, Sparkles } from "lucide-react"; import { Button } from "@/components/atoms/button"; import { CardBadge } from "@/features/services/components/base/CardBadge"; import { cn } from "@/shared/utils"; @@ -38,15 +38,15 @@ interface PublicOfferingCardProps { const tierStyles = { Silver: { - card: "border-muted-foreground/20 bg-card", - accent: "text-muted-foreground", + card: "border-gray-200 bg-white border-l-4 border-l-gray-400", + accent: "text-gray-600", }, Gold: { - card: "border-warning/30 bg-warning-soft/20", - accent: "text-warning", + card: "border-gray-200 bg-white border-l-4 border-l-amber-500", + accent: "text-amber-600", }, Platinum: { - card: "border-primary/30 bg-info-soft/20", + card: "border-gray-200 bg-white border-l-4 border-l-primary", accent: "text-primary", }, } as const; @@ -213,12 +213,24 @@ export function PublicOfferingCard({
+ {/* Popular Badge for Gold */} + {tier.tier === "Gold" && ( +
+ + + Popular + +
+ )} + {/* Header */} -
+
{tier.tier} @@ -235,7 +247,11 @@ export function PublicOfferingCard({ /mo {tier.pricingNote && ( - {tier.pricingNote} + + {tier.pricingNote} + )}
@@ -247,7 +263,17 @@ export function PublicOfferingCard({
    {tier.features.slice(0, 3).map((feature, index) => (
  • - + + + {feature}
  • ))} diff --git a/apps/portal/src/features/services/components/sim/SimHowItWorksSection.tsx b/apps/portal/src/features/services/components/sim/SimHowItWorksSection.tsx new file mode 100644 index 00000000..a1b02225 --- /dev/null +++ b/apps/portal/src/features/services/components/sim/SimHowItWorksSection.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Signal, FileCheck, Send, CheckCircle } from "lucide-react"; + +interface StepProps { + number: number; + icon: React.ReactNode; + title: string; + description: string; +} + +function Step({ number, icon, title, description }: StepProps) { + return ( +
    + {/* Icon with number badge */} +
    +
    + {icon} +
    + {/* Number badge */} +
    + {number} +
    +
    + + {/* Content */} +

    {title}

    +

    {description}

    +
    + ); +} + +export function SimHowItWorksSection() { + const steps = [ + { + icon: , + title: "Choose Plan", + description: "Select your data and voice options", + }, + { + icon: , + title: "Create Account", + description: "Sign up with email verification", + }, + { + icon: , + title: "Place Order", + description: "Configure SIM type and pay", + }, + { + icon: , + title: "Get Connected", + description: "Receive SIM and activate", + }, + ]; + + return ( +
    + {/* Header */} +
    + + Getting Started + +

    How It Works

    +
    + + {/* Steps with connecting line */} +
    + {/* Connecting line - hidden on mobile */} +
    + + {/* Curved path SVG for visual connection - hidden on mobile */} + + + + + {/* Steps grid */} +
    + {steps.map((step, index) => ( + + ))} +
    +
    +
    + ); +} diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx index 532b2e22..142b1dd2 100644 --- a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -30,6 +30,7 @@ import { ServiceHighlights, type HighlightFeature, } from "@/features/services/components/base/ServiceHighlights"; +import { SimHowItWorksSection } from "@/features/services/components/sim/SimHowItWorksSection"; export type SimPlansTab = "data-voice" | "data-only" | "voice-only"; @@ -397,6 +398,11 @@ export function SimPlansContent({ )}
+ {/* How It Works Section */} +
+ +
+
diff --git a/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx index 036233ea..af349ec8 100644 --- a/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx +++ b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx @@ -2,32 +2,71 @@ import { AnimatedCard } from "@/components/molecules"; import { Button } from "@/components/atoms/button"; -import { ArrowRight, ShieldCheck } from "lucide-react"; +import { ArrowRight, Globe, Check } from "lucide-react"; import type { VpnCatalogProduct } from "@customer-portal/domain/services"; -import { CardPricing } from "@/features/services/components/base/CardPricing"; interface VpnPlanCardProps { plan: VpnCatalogProduct; } +const vpnFeatures = [ + "Secure VPN connection", + "Pre-configured router", + "Easy plug & play setup", + "English support included", +]; + export function VpnPlanCard({ plan }: VpnPlanCardProps) { return ( - + {/* Header with icon and name */} -
-
- +
+
+
+ +
+
+

{plan.name}

+ International +
-
-

{plan.name}

+
+
{/* Pricing */} -
- +
+
+ ¥ + + {(plan.monthlyPrice ?? 0).toLocaleString()} + + /month +
+

Router rental included

+ {/* Features list */} +
    + {vpnFeatures.map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+ {/* Action Button */}
diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index cec99890..2debd225 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -28,22 +28,23 @@ import { ServiceHighlights, HighlightFeature, } from "@/features/services/components/base/ServiceHighlights"; +import { HowItWorksSection } from "@/features/services/components/internet/HowItWorksSection"; import { cn } from "@/shared/utils"; -// Tier styling +// Tier styling - matching the design with left border accents const tierStyles = { Silver: { - card: "border-muted-foreground/20 bg-card", - accent: "text-muted-foreground", + card: "border-gray-200 bg-white border-l-4 border-l-gray-400", + accent: "text-gray-600", header: "Silver", }, Gold: { - card: "border-warning/30 bg-warning-soft/20", - accent: "text-warning", + card: "border-gray-200 bg-white border-l-4 border-l-amber-500", + accent: "text-amber-600", header: "Gold", }, Platinum: { - card: "border-primary/30 bg-info-soft/20", + card: "border-gray-200 bg-white border-l-4 border-l-primary", accent: "text-primary", header: "Platinum", }, @@ -110,28 +111,48 @@ function ConsolidatedInternetCard({
+ {/* Popular Badge for Gold */} + {tier.tier === "Gold" && ( +
+ + + Popular + +
+ )} + {/* Tier Name */} -

+

{tier.tier}

{/* Price Range */}
- + ¥{tier.monthlyPrice.toLocaleString()} {tier.maxMonthlyPrice && tier.maxMonthlyPrice > tier.monthlyPrice && `~${tier.maxMonthlyPrice.toLocaleString()}`} - /mo + /mo
{tier.pricingNote && ( - {tier.pricingNote} + + {tier.pricingNote} + )}
@@ -142,7 +163,17 @@ function ConsolidatedInternetCard({
    {tier.features.map((feature, index) => (
  • - + + + {feature}
  • ))} @@ -408,6 +439,9 @@ export function PublicInternetPlansContent({ ) : null}
+ {/* How It Works Section */} + + {/* Final CTA - Polished */}
diff --git a/apps/portal/src/features/services/views/PublicVpnPlans.tsx b/apps/portal/src/features/services/views/PublicVpnPlans.tsx index 6751375d..ef14eda8 100644 --- a/apps/portal/src/features/services/views/PublicVpnPlans.tsx +++ b/apps/portal/src/features/services/views/PublicVpnPlans.tsx @@ -1,7 +1,19 @@ "use client"; import { useState } from "react"; -import { ShieldCheck, Zap, ChevronDown, ChevronUp } from "lucide-react"; +import { + ShieldCheck, + ChevronDown, + ChevronUp, + Router, + Globe, + Tv, + Wifi, + Package, + Headphones, + CreditCard, + Play, +} from "lucide-react"; import { usePublicVpnCatalog } from "@/features/services/hooks"; import { LoadingCard } from "@/components/atoms"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; @@ -10,6 +22,10 @@ import { VpnPlanCard } from "@/features/services/components/vpn/VpnPlanCard"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { ServicesHero } from "@/features/services/components/base/ServicesHero"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { + ServiceHighlights, + type HighlightFeature, +} from "@/features/services/components/base/ServiceHighlights"; /** * Public VPN Plans View @@ -45,6 +61,45 @@ export function PublicVpnPlansView() { ); } + const vpnFeatures: HighlightFeature[] = [ + { + icon: , + title: "Pre-configured Router", + description: "Ready to use out of the box — just plug in and connect", + highlight: "Plug & play", + }, + { + icon: , + title: "US & UK Servers", + description: "Access content from San Francisco or London regions", + highlight: "2 locations", + }, + { + icon: , + title: "Streaming Ready", + description: "Works with Apple TV, Roku, Amazon Fire, and more", + highlight: "All devices", + }, + { + icon: , + title: "Separate Network", + description: "VPN runs on dedicated WiFi, keep regular internet normal", + highlight: "No interference", + }, + { + icon: , + title: "Router Rental Included", + description: "No equipment purchase — router rental is part of the plan", + highlight: "No hidden costs", + }, + { + icon: , + title: "English Support", + description: "Full English assistance for setup and troubleshooting", + highlight: "Dedicated help", + }, + ]; + return (
@@ -52,28 +107,22 @@ export function PublicVpnPlansView() { - {/* Order info banner */} -
-
- -

- Order today - - {" "} - — create account, add payment, and your router ships upon confirmation. - -

-
-
-
+ /> + + {/* Service Highlights */} + {vpnPlans.length > 0 ? (
-

Choose Your Region

-

- Select one region per router rental -

+
+ + Choose Your Region + +

Available Plans

+

+ Select one region per router rental +

+
{vpnPlans.map(plan => ( @@ -103,23 +152,8 @@ export function PublicVpnPlansView() {
)} -
-

How It Works

-
-

- SonixNet VPN is the easiest way to access video streaming services from overseas on your - network media players such as an Apple TV, Roku, or Amazon Fire. -

-

- A configured Wi-Fi router is provided for rental (no purchase required, no hidden fees). - All you need to do is plug the VPN router into your existing internet connection. -

-

- Connect your network media players to the VPN Wi-Fi network to access content from the - selected region. For regular internet usage, use your normal home Wi-Fi. -

-
-
+ {/* How It Works Section */} + {/* FAQ Section */} @@ -196,4 +230,102 @@ function VpnFaqSection() { ); } +interface HowItWorksStepProps { + number: number; + icon: React.ReactNode; + title: string; + description: string; +} + +function HowItWorksStep({ number, icon, title, description }: HowItWorksStepProps) { + return ( +
+ {/* Icon with number badge */} +
+
+ {icon} +
+ {/* Number badge */} +
+ {number} +
+
+ + {/* Content */} +

{title}

+

{description}

+
+ ); +} + +function VpnHowItWorksSection() { + const steps = [ + { + icon: , + title: "Sign Up", + description: "Create your account to get started", + }, + { + icon: , + title: "Choose Region", + description: "Select US (San Francisco) or UK (London)", + }, + { + icon: , + title: "Place Order", + description: "Complete checkout and receive router", + }, + { + icon: , + title: "Connect & Stream", + description: "Plug in, connect devices, enjoy", + }, + ]; + + return ( +
+ {/* Header */} +
+ + Simple Setup + +

How It Works

+
+ + {/* Steps with connecting line */} +
+ {/* Connecting line - hidden on mobile */} +
+ + {/* Curved path SVG for visual connection - hidden on mobile */} + + + + + {/* Steps grid */} +
+ {steps.map((step, index) => ( + + ))} +
+
+
+ ); +} + export default PublicVpnPlansView; diff --git a/docs/testing/sim-api-test-tracking.md b/docs/testing/sim-api-test-tracking.md new file mode 100644 index 00000000..f0cf1233 --- /dev/null +++ b/docs/testing/sim-api-test-tracking.md @@ -0,0 +1,87 @@ +# SIM API Test Tracking + +## Overview + +The test tracking system automatically logs all Freebit API calls made during physical SIM testing to a CSV file. This allows you to track which APIs were tested on which phones and at what time. + +## Log File Location + +The test tracking log file is created at the project root: + +- **File**: `sim-api-test-log.csv` +- **Location**: Project root directory + +## Log Format + +The CSV file contains the following columns: + +1. **Timestamp** - ISO 8601 timestamp of when the API was called +2. **API Endpoint** - The Freebit API endpoint (e.g., `/mvno/getDetail/`, `/master/addSpec/`) +3. **API Method** - HTTP method (typically "POST") +4. **Phone Number** - The phone number/SIM account identifier +5. **SIM Identifier** - Additional SIM identifier if available +6. **Request Payload** - JSON string of the request (sensitive data redacted) +7. **Response Status** - Success or error status code +8. **Error** - Error message if the call failed +9. **Additional Info** - Additional status information + +## What Gets Tracked + +The system automatically tracks all Freebit API calls that include a phone number/account identifier, including: + +- **Physical SIM Activation** (`/master/addSpec/` or `/mvno/eachQuota/`) +- **SIM Top-up** (`/master/addSpec/` or `/mvno/eachQuota/`) +- **SIM Details** (`/mvno/getDetail/`) +- **SIM Usage** (`/mvno/getTrafficInfo/`) +- **Plan Changes** (`/mvno/changePlan/`) +- **SIM Cancellation** (`/mvno/releasePlan/`) +- **eSIM Operations** (`/esim/reissueProfile/`, `/mvno/esim/addAcnt/`) +- **Quota History** (`/mvno/getQuotaHistory/`) + +## Usage + +The tracking is **automatic** - no code changes needed. Every time a Freebit API is called with a phone number, it will be logged to the CSV file. + +### Example Log Entry + +```csv +Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Response Status,Error,Additional Info +2025-01-15T10:30:45.123Z,/master/addSpec/,POST,02000002470001,02000002470001,"{""account"":""02000002470001"",""quota"":0}",Success,,"OK" +``` + +## Test Data Files + +The following CSV files contain test SIM data: + +- `ASI_N6_PASI_20251229.csv` - 半黒データ (Half-black data) - 50 SIMs (rows 1-50) +- `ASI_N7_PASI_20251229.csv` - 半黒音声 (Half-black voice) - 50 SIMs (rows 51-100) + +Each row contains: + +- Row number +- SIM number (phone number) +- PT number +- PASI identifier +- Date (20251229) + +## Notes + +- The tracking system only logs API calls that include a phone number/account identifier +- Tracking failures do not affect API functionality - if logging fails, the API call still proceeds +- The log file is append-only - new entries are added to the end of the file +- Request payloads are redacted for security (sensitive data like auth keys are removed) + +## Viewing the Log + +You can view the log file using any CSV viewer or text editor: + +```bash +# View the log file +cat sim-api-test-log.csv + +# View last 20 entries +tail -20 sim-api-test-log.csv + +# Filter by phone number +grep "02000002470001" sim-api-test-log.csv +```