Enhance Freebit Integration and Update UI Components

- Added FreebitTestTrackerService to FreebitClientService for improved API call tracking.
- Updated Freebit module to include the new test tracker service.
- Refactored HowItWorksSection and PublicInternetPlans to enhance user guidance with clearer steps and improved styling.
- Introduced a new VpnHowItWorksSection to provide users with a simplified setup process for VPN services.
- Enhanced PublicOfferingCard and VpnPlanCard components with updated styles and additional feature highlights for better user engagement.
This commit is contained in:
tema 2026-01-17 18:47:13 +09:00
parent ab3561ba5c
commit 94b341dd93
14 changed files with 1033 additions and 272 deletions

50
ASI_N6_PASI_20251229.csv Normal file
View File

@ -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,,,,
1 1 02000002470001 PT0220024700010 PASI 20251229
2 2 02000002470002 PT0220024700020 PASI 20251229
3 3 02000002470003 PT0220024700030 PASI 20251229
4 4 02000002470004 PT0220024700040 PASI 20251229
5 5 02000002470005 PT0220024700050 PASI 20251229
6 6 02000002470006 PT0220024700060 PASI 20251229
7 7 02000002470007 PT0220024700070 PASI 20251229
8 8 02000002470008 PT0220024700080 PASI 20251229
9 9 02000002470009 PT0220024700090 PASI 20251229
10 10 02000002470010 PT0220024700100 PASI 20251229
11 11 02000002470011 PT0220024700110 PASI 20251229
12 12 02000002470012 PT0220024700120 PASI 20251229
13 13 02000002470013 PT0220024700130 PASI 20251229
14 14 02000002470014 PT0220024700140 PASI 20251229
15 15 02000002470015 PT0220024700150 PASI 20251229
16 16 02000002470016 PT0220024700160 PASI 20251229
17 17 02000002470017 PT0220024700170 PASI 20251229
18 18 02000002470018 PT0220024700180 PASI 20251229
19 19 02000002470019 PT0220024700190 PASI 20251229
20 20 02000002470020 PT0220024700200 PASI 20251229
21 21 02000002470021 PT0220024700210 PASI 20251229
22 22 02000002470022 PT0220024700220 PASI 20251229
23 23 02000002470023 PT0220024700230 PASI 20251229
24 24 02000002470024 PT0220024700240 PASI 20251229
25 25 02000002470025 PT0220024700250 PASI 20251229
26 26 02000002470026 PT0220024700260 PASI 20251229
27 27 02000002470027 PT0220024700270 PASI 20251229
28 28 02000002470028 PT0220024700280 PASI 20251229
29 29 02000002470029 PT0220024700290 PASI 20251229
30 30 02000002470030 PT0220024700300 PASI 20251229
31 31 02000002470031 PT0220024700310 PASI 20251229
32 32 02000002470032 PT0220024700320 PASI 20251229
33 33 02000002470033 PT0220024700330 PASI 20251229
34 34 02000002470034 PT0220024700340 PASI 20251229
35 35 02000002470035 PT0220024700350 PASI 20251229
36 36 02000002470036 PT0220024700360 PASI 20251229
37 37 02000002470037 PT0220024700370 PASI 20251229
38 38 02000002470038 PT0220024700380 PASI 20251229
39 39 02000002470039 PT0220024700390 PASI 20251229
40 40 02000002470040 PT0220024700400 PASI 20251229
41 41 02000002470041 PT0220024700410 PASI 20251229
42 42 02000002470042 PT0220024700420 PASI 20251229
43 43 02000002470043 PT0220024700430 PASI 20251229
44 44 02000002470044 PT0220024700440 PASI 20251229
45 45 02000002470045 PT0220024700450 PASI 20251229
46 46 02000002470046 PT0220024700460 PASI 20251229
47 47 02000002470047 PT0220024700470 PASI 20251229
48 48 02000002470048 PT0220024700480 PASI 20251229
49 49 02000002470049 PT0220024700490 PASI 20251229
50 50 02000002470050 PT0220024700500 PASI 20251229

50
ASI_N7_PASI_20251229.csv Normal file
View File

@ -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,,,,
1 51 07000240001 PT0270002400010 PASI 20251229
2 52 07000240002 PT0270002400020 PASI 20251229
3 53 07000240003 PT0270002400030 PASI 20251229
4 54 07000240004 PT0270002400040 PASI 20251229
5 55 07000240005 PT0270002400050 PASI 20251229
6 56 07000240006 PT0270002400060 PASI 20251229
7 57 07000240007 PT0270002400070 PASI 20251229
8 58 07000240008 PT0270002400080 PASI 20251229
9 59 07000240009 PT0270002400090 PASI 20251229
10 60 07000240010 PT0270002400100 PASI 20251229
11 61 07000240011 PT0270002400110 PASI 20251229
12 62 07000240012 PT0270002400120 PASI 20251229
13 63 07000240013 PT0270002400130 PASI 20251229
14 64 07000240014 PT0270002400140 PASI 20251229
15 65 07000240015 PT0270002400150 PASI 20251229
16 66 07000240016 PT0270002400160 PASI 20251229
17 67 07000240017 PT0270002400170 PASI 20251229
18 68 07000240018 PT0270002400180 PASI 20251229
19 69 07000240019 PT0270002400190 PASI 20251229
20 70 07000240020 PT0270002400200 PASI 20251229
21 71 07000240021 PT0270002400210 PASI 20251229
22 72 07000240022 PT0270002400220 PASI 20251229
23 73 07000240023 PT0270002400230 PASI 20251229
24 74 07000240024 PT0270002400240 PASI 20251229
25 75 07000240025 PT0270002400250 PASI 20251229
26 76 07000240026 PT0270002400260 PASI 20251229
27 77 07000240027 PT0270002400270 PASI 20251229
28 78 07000240028 PT0270002400280 PASI 20251229
29 79 07000240029 PT0270002400290 PASI 20251229
30 80 07000240030 PT0270002400300 PASI 20251229
31 81 07000240031 PT0270002400310 PASI 20251229
32 82 07000240032 PT0270002400320 PASI 20251229
33 83 07000240033 PT0270002400330 PASI 20251229
34 84 07000240034 PT0270002400340 PASI 20251229
35 85 07000240035 PT0270002400350 PASI 20251229
36 86 07000240036 PT0270002400360 PASI 20251229
37 87 07000240037 PT0270002400370 PASI 20251229
38 88 07000240038 PT0270002400380 PASI 20251229
39 89 07000240039 PT0270002400390 PASI 20251229
40 90 07000240040 PT0270002400400 PASI 20251229
41 91 07000240041 PT0270002400410 PASI 20251229
42 92 07000240042 PT0270002400420 PASI 20251229
43 93 07000240043 PT0270002400430 PASI 20251229
44 94 07000240044 PT0270002400440 PASI 20251229
45 95 07000240045 PT0270002400450 PASI 20251229
46 96 07000240046 PT0270002400460 PASI 20251229
47 97 07000240047 PT0270002400470 PASI 20251229
48 98 07000240048 PT0270002400480 PASI 20251229
49 99 07000240049 PT0270002400490 PASI 20251229
50 100 07000240050 PT0270002400500 PASI 20251229

View File

@ -0,0 +1 @@
Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Response Status,Error,Additional Info
1 Timestamp API Endpoint API Method Phone Number SIM Identifier Request Payload Response Status Error Additional Info

View File

@ -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,

View File

@ -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<void> {
const payloadObj = payload as Record<string, unknown>;
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,
});
}
}

View File

@ -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<void> {
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<void> {
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<string, unknown>;
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();
}
}

View File

@ -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 (
<div className="relative flex items-start gap-4">
{/* Step number with icon */}
<div className="flex flex-col items-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 border-2 border-primary/30 text-primary">
<div className="flex flex-col items-center text-center flex-1 min-w-0">
{/* Icon with number badge */}
<div className="relative mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-gray-50 border border-gray-200 text-primary shadow-sm">
{icon}
</div>
{/* Connector line */}
{!isLast && (
<div className="w-0.5 h-full min-h-[3rem] bg-gradient-to-b from-primary/30 to-transparent mt-2" />
)}
{/* Number badge */}
<div className="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white text-xs font-bold shadow-sm">
{number}
</div>
</div>
{/* Content */}
<div className="flex-1 pb-8">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-primary uppercase tracking-wider">
Step {number}
</span>
</div>
<h4 className="font-semibold text-foreground mb-1">{title}</h4>
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
</div>
<h4 className="font-semibold text-foreground mb-2">{title}</h4>
<p className="text-sm text-muted-foreground leading-relaxed max-w-[180px]">{description}</p>
</div>
);
}
@ -46,51 +33,68 @@ function Step({ number, icon, title, description, isLast = false }: StepProps) {
export function HowItWorksSection() {
const steps = [
{
icon: <UserPlusIcon className="h-5 w-5" />,
title: "Create your account",
description:
"Sign up with your email and provide your service address. This only takes a minute.",
icon: <MapPin className="h-6 w-6" />,
title: "Enter Address",
description: "Submit your address for coverage check",
},
{
icon: <MagnifyingGlassIcon className="h-5 w-5" />,
title: "We verify with NTT",
description:
"Our team checks what service is available at your address. This takes 1-2 business days.",
icon: <Settings className="h-6 w-6" />,
title: "We Verify",
description: "Our team checks with NTT (1-2 days)",
},
{
icon: <CheckBadgeIcon className="h-5 w-5" />,
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: <Calendar className="h-6 w-6" />,
title: "Sign Up & Order",
description: "Create account and select your plan",
},
{
icon: <RocketLaunchIcon className="h-5 w-5" />,
title: "Get connected",
description:
"We coordinate NTT installation and set up your service. You'll be online in no time.",
icon: <Wifi className="h-6 w-6" />,
title: "Get Connected",
description: "NTT installs fiber at your home",
},
];
return (
<section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-6">
<div className="mb-6">
<h3 className="text-lg font-bold text-foreground mb-1">How it works</h3>
<p className="text-sm text-muted-foreground">
Getting connected is simple. Here's what to expect.
</p>
<section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-8">
{/* Header */}
<div className="text-center mb-8">
<span className="text-sm font-semibold text-primary uppercase tracking-wider">
Getting Started
</span>
<h3 className="text-2xl font-bold text-foreground mt-1">How It Works</h3>
</div>
<div className="space-y-0">
{steps.map((step, index) => (
<Step
key={index}
number={index + 1}
icon={step.icon}
title={step.title}
description={step.description}
isLast={index === steps.length - 1}
{/* Steps with connecting line */}
<div className="relative">
{/* Connecting line - hidden on mobile */}
<div className="hidden md:block absolute top-8 left-[12%] right-[12%] h-0.5 bg-gray-200" />
{/* Curved path SVG for visual connection - hidden on mobile */}
<svg
className="hidden md:block absolute top-[30px] left-0 right-0 w-full h-4 pointer-events-none"
preserveAspectRatio="none"
>
<path
d="M 12% 8 Q 30% 8, 37.5% 8 Q 45% 8, 50% 8 Q 55% 8, 62.5% 8 Q 70% 8, 88% 8"
fill="none"
stroke="#e5e7eb"
strokeWidth="2"
strokeDasharray="6 4"
/>
))}
</svg>
{/* Steps grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 relative z-10">
{steps.map((step, index) => (
<Step
key={index}
number={index + 1}
icon={step.icon}
title={step.title}
description={step.description}
/>
))}
</div>
</div>
</section>
);

View File

@ -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({
<div
key={tier.tier}
className={cn(
"rounded-lg border p-3 transition-all duration-200 flex flex-col",
"rounded-lg border p-3 transition-all duration-200 flex flex-col relative",
tierStyles[tier.tier].card
)}
>
{/* Popular Badge for Gold */}
{tier.tier === "Gold" && (
<div className="absolute -top-2.5 left-1/2 -translate-x-1/2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-500 text-white text-[10px] font-semibold shadow-sm">
<Sparkles className="h-2.5 w-2.5" />
Popular
</span>
</div>
)}
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<div
className={cn("flex items-center gap-2 mb-2", tier.tier === "Gold" ? "mt-1" : "")}
>
<span className={cn("font-semibold text-sm", tierStyles[tier.tier].accent)}>
{tier.tier}
</span>
@ -235,7 +247,11 @@ export function PublicOfferingCard({
</span>
<span className="text-xs text-muted-foreground">/mo</span>
{tier.pricingNote && (
<span className="text-[10px] text-warning ml-1">{tier.pricingNote}</span>
<span
className={`text-[10px] ml-1 ${tier.tier === "Platinum" ? "text-primary" : "text-amber-600"}`}
>
{tier.pricingNote}
</span>
)}
</div>
</div>
@ -247,7 +263,17 @@ export function PublicOfferingCard({
<ul className="space-y-1 flex-grow">
{tier.features.slice(0, 3).map((feature, index) => (
<li key={index} className="flex items-start gap-1.5 text-xs">
<Zap className="h-3 w-3 text-success flex-shrink-0 mt-0.5" />
<svg
className="h-3 w-3 text-gray-500 flex-shrink-0 mt-0.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-muted-foreground leading-relaxed">{feature}</span>
</li>
))}

View File

@ -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 (
<div className="flex flex-col items-center text-center flex-1 min-w-0">
{/* Icon with number badge */}
<div className="relative mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-gray-50 border border-gray-200 text-primary shadow-sm">
{icon}
</div>
{/* Number badge */}
<div className="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white text-xs font-bold shadow-sm">
{number}
</div>
</div>
{/* Content */}
<h4 className="font-semibold text-foreground mb-2">{title}</h4>
<p className="text-sm text-muted-foreground leading-relaxed max-w-[180px]">{description}</p>
</div>
);
}
export function SimHowItWorksSection() {
const steps = [
{
icon: <Signal className="h-6 w-6" />,
title: "Choose Plan",
description: "Select your data and voice options",
},
{
icon: <FileCheck className="h-6 w-6" />,
title: "Create Account",
description: "Sign up with email verification",
},
{
icon: <Send className="h-6 w-6" />,
title: "Place Order",
description: "Configure SIM type and pay",
},
{
icon: <CheckCircle className="h-6 w-6" />,
title: "Get Connected",
description: "Receive SIM and activate",
},
];
return (
<section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-8 mb-8">
{/* Header */}
<div className="text-center mb-8">
<span className="text-sm font-semibold text-primary uppercase tracking-wider">
Getting Started
</span>
<h3 className="text-2xl font-bold text-foreground mt-1">How It Works</h3>
</div>
{/* Steps with connecting line */}
<div className="relative">
{/* Connecting line - hidden on mobile */}
<div className="hidden md:block absolute top-8 left-[12%] right-[12%] h-0.5 bg-gray-200" />
{/* Curved path SVG for visual connection - hidden on mobile */}
<svg
className="hidden md:block absolute top-[30px] left-0 right-0 w-full h-4 pointer-events-none"
preserveAspectRatio="none"
>
<path
d="M 12% 8 Q 30% 8, 37.5% 8 Q 45% 8, 50% 8 Q 55% 8, 62.5% 8 Q 70% 8, 88% 8"
fill="none"
stroke="#e5e7eb"
strokeWidth="2"
strokeDasharray="6 4"
/>
</svg>
{/* Steps grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 relative z-10">
{steps.map((step, index) => (
<Step
key={index}
number={index + 1}
icon={step.icon}
title={step.title}
description={step.description}
/>
))}
</div>
</div>
</section>
);
}

View File

@ -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({
)}
</div>
{/* How It Works Section */}
<div className="mt-12">
<SimHowItWorksSection />
</div>
<div className="mt-8 space-y-4">
<CollapsibleSection title="Calling & SMS Rates" icon={Phone}>
<div className="space-y-6 pt-4">

View File

@ -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 (
<AnimatedCard className="p-6 border border-primary/20 hover:border-primary/40 transition-all duration-300 hover:shadow-lg flex flex-col h-full">
<AnimatedCard className="p-6 border border-border hover:border-primary/40 transition-all duration-300 hover:shadow-lg flex flex-col h-full bg-white">
{/* Header with icon and name */}
<div className="flex items-start gap-3 mb-4">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldCheck className="h-6 w-6 text-primary" />
<div className="flex items-start justify-between mb-4">
<div className="flex items-start gap-3">
<div className="p-2.5 bg-primary/10 rounded-xl">
<Globe className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-foreground">{plan.name}</h3>
<span className="text-sm text-muted-foreground">International</span>
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-foreground">{plan.name}</h3>
<div className="p-1.5 rounded-full border border-primary/30">
<Check className="h-4 w-4 text-primary" />
</div>
</div>
{/* Pricing */}
<div className="mb-6">
<CardPricing monthlyPrice={plan.monthlyPrice} size="lg" alignment="left" />
<div className="mb-4">
<div className="flex items-baseline gap-1">
<span className="text-sm text-primary">¥</span>
<span className="text-3xl font-bold text-foreground">
{(plan.monthlyPrice ?? 0).toLocaleString()}
</span>
<span className="text-sm text-muted-foreground">/month</span>
</div>
<p className="text-xs text-muted-foreground mt-1">Router rental included</p>
</div>
{/* Features list */}
<ul className="space-y-2 mb-6 flex-grow">
{vpnFeatures.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<svg
className="h-4 w-4 text-gray-500 flex-shrink-0 mt-0.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-muted-foreground">{feature}</span>
</li>
))}
</ul>
{/* Action Button */}
<div className="mt-auto">
<Button
@ -36,7 +75,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
className="w-full"
rightIcon={<ArrowRight className="w-4 h-4" />}
>
Continue to Checkout
Select {plan.name}
</Button>
</div>
</AnimatedCard>

View File

@ -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({
<div
key={tier.tier}
className={cn(
"rounded-xl border p-4 transition-all duration-200 flex flex-col",
"rounded-xl border p-4 transition-all duration-200 flex flex-col relative",
tierStyles[tier.tier].card
)}
>
{/* Popular Badge for Gold */}
{tier.tier === "Gold" && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-amber-500 text-white text-xs font-semibold shadow-sm">
<Sparkles className="h-3 w-3" />
Popular
</span>
</div>
)}
{/* Tier Name */}
<h4 className={cn("font-bold text-lg mb-2", tierStyles[tier.tier].accent)}>
<h4
className={cn(
"font-bold text-lg mb-2",
tier.tier === "Gold" ? "mt-2" : "",
tierStyles[tier.tier].accent
)}
>
{tier.tier}
</h4>
{/* Price Range */}
<div className="mb-3">
<div className="flex items-baseline gap-0.5 flex-wrap">
<span className="text-xl font-bold text-foreground">
<span className="text-2xl font-bold text-foreground">
¥{tier.monthlyPrice.toLocaleString()}
{tier.maxMonthlyPrice &&
tier.maxMonthlyPrice > tier.monthlyPrice &&
`~${tier.maxMonthlyPrice.toLocaleString()}`}
</span>
<span className="text-xs text-muted-foreground">/mo</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
{tier.pricingNote && (
<span className="text-xs text-warning">{tier.pricingNote}</span>
<span
className={`text-xs ${tier.tier === "Platinum" ? "text-primary" : "text-amber-600"}`}
>
{tier.pricingNote}
</span>
)}
</div>
@ -142,7 +163,17 @@ function ConsolidatedInternetCard({
<ul className="space-y-2 flex-grow">
{tier.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<Zap className="h-4 w-4 text-success flex-shrink-0 mt-0.5" />
<svg
className="h-4 w-4 text-gray-500 flex-shrink-0 mt-0.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<span className="text-muted-foreground">{feature}</span>
</li>
))}
@ -408,6 +439,9 @@ export function PublicInternetPlansContent({
) : null}
</section>
{/* How It Works Section */}
<HowItWorksSection />
{/* Final CTA - Polished */}
<section className="text-center py-8 mt-4 rounded-2xl bg-gradient-to-br from-primary/5 via-transparent to-info/5 border border-border/50">
<div className="inline-flex items-center gap-1.5 text-xs font-medium text-primary mb-3 px-3 py-1 rounded-full bg-primary/10">

View File

@ -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: <Router className="h-6 w-6" />,
title: "Pre-configured Router",
description: "Ready to use out of the box — just plug in and connect",
highlight: "Plug & play",
},
{
icon: <Globe className="h-6 w-6" />,
title: "US & UK Servers",
description: "Access content from San Francisco or London regions",
highlight: "2 locations",
},
{
icon: <Tv className="h-6 w-6" />,
title: "Streaming Ready",
description: "Works with Apple TV, Roku, Amazon Fire, and more",
highlight: "All devices",
},
{
icon: <Wifi className="h-6 w-6" />,
title: "Separate Network",
description: "VPN runs on dedicated WiFi, keep regular internet normal",
highlight: "No interference",
},
{
icon: <Package className="h-6 w-6" />,
title: "Router Rental Included",
description: "No equipment purchase — router rental is part of the plan",
highlight: "No hidden costs",
},
{
icon: <Headphones className="h-6 w-6" />,
title: "English Support",
description: "Full English assistance for setup and troubleshooting",
highlight: "Dedicated help",
},
];
return (
<div className="max-w-6xl mx-auto px-4 pb-16">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
@ -52,28 +107,22 @@ export function PublicVpnPlansView() {
<ServicesHero
title="VPN Router Service"
description="Secure VPN connections to San Francisco or London using a pre-configured router."
>
{/* Order info banner */}
<div className="bg-success-soft border border-success/25 rounded-xl px-4 py-3 max-w-xl mt-4">
<div className="flex items-center gap-2 justify-center">
<Zap className="h-4 w-4 text-success flex-shrink-0" />
<p className="text-sm text-foreground">
<span className="font-medium">Order today</span>
<span className="text-muted-foreground">
{" "}
create account, add payment, and your router ships upon confirmation.
</span>
</p>
</div>
</div>
</ServicesHero>
/>
{/* Service Highlights */}
<ServiceHighlights features={vpnFeatures} className="mb-12" />
{vpnPlans.length > 0 ? (
<div className="mb-8">
<h2 className="text-xl font-bold text-foreground mb-2 text-center">Choose Your Region</h2>
<p className="text-sm text-muted-foreground text-center mb-6">
Select one region per router rental
</p>
<div className="text-center mb-8">
<span className="text-sm font-semibold text-primary uppercase tracking-wider">
Choose Your Region
</span>
<h2 className="text-2xl font-bold text-foreground mt-1">Available Plans</h2>
<p className="text-sm text-muted-foreground mt-2">
Select one region per router rental
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{vpnPlans.map(plan => (
@ -103,23 +152,8 @@ export function PublicVpnPlansView() {
</div>
)}
<div className="bg-card rounded-xl border border-border p-8 mb-8">
<h2 className="text-xl font-bold text-foreground mb-6">How It Works</h2>
<div className="space-y-4 text-sm text-muted-foreground">
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
</div>
</div>
{/* How It Works Section */}
<VpnHowItWorksSection />
{/* FAQ Section */}
<VpnFaqSection />
@ -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 (
<div className="flex flex-col items-center text-center flex-1 min-w-0">
{/* Icon with number badge */}
<div className="relative mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-gray-50 border border-gray-200 text-primary shadow-sm">
{icon}
</div>
{/* Number badge */}
<div className="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white text-xs font-bold shadow-sm">
{number}
</div>
</div>
{/* Content */}
<h4 className="font-semibold text-foreground mb-2">{title}</h4>
<p className="text-sm text-muted-foreground leading-relaxed max-w-[180px]">{description}</p>
</div>
);
}
function VpnHowItWorksSection() {
const steps = [
{
icon: <CreditCard className="h-6 w-6" />,
title: "Sign Up",
description: "Create your account to get started",
},
{
icon: <Globe className="h-6 w-6" />,
title: "Choose Region",
description: "Select US (San Francisco) or UK (London)",
},
{
icon: <Package className="h-6 w-6" />,
title: "Place Order",
description: "Complete checkout and receive router",
},
{
icon: <Play className="h-6 w-6" />,
title: "Connect & Stream",
description: "Plug in, connect devices, enjoy",
},
];
return (
<section className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-8 mb-8">
{/* Header */}
<div className="text-center mb-8">
<span className="text-sm font-semibold text-primary uppercase tracking-wider">
Simple Setup
</span>
<h3 className="text-2xl font-bold text-foreground mt-1">How It Works</h3>
</div>
{/* Steps with connecting line */}
<div className="relative">
{/* Connecting line - hidden on mobile */}
<div className="hidden md:block absolute top-8 left-[12%] right-[12%] h-0.5 bg-gray-200" />
{/* Curved path SVG for visual connection - hidden on mobile */}
<svg
className="hidden md:block absolute top-[30px] left-0 right-0 w-full h-4 pointer-events-none"
preserveAspectRatio="none"
>
<path
d="M 12% 8 Q 30% 8, 37.5% 8 Q 45% 8, 50% 8 Q 55% 8, 62.5% 8 Q 70% 8, 88% 8"
fill="none"
stroke="#e5e7eb"
strokeWidth="2"
strokeDasharray="6 4"
/>
</svg>
{/* Steps grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 relative z-10">
{steps.map((step, index) => (
<HowItWorksStep
key={index}
number={index + 1}
icon={step.icon}
title={step.title}
description={step.description}
/>
))}
</div>
</div>
</section>
);
}
export default PublicVpnPlansView;

View File

@ -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
```