Merge main into alt-design

Resolved merge conflicts between main and alt-design branches.

Key decisions:
- BFF: Adopted SIM-first workflow from main (PA05-18 → PA02-01 → PA05-05 → WHMCS)
- BFF: Kept FreebitFacade pattern, added new services (AccountRegistration, VoiceOptions, SemiBlack)
- BFF: Fixed freebit-usage.service.ts bug (quotaKb → quotaMb)
- BFF: Merged rate limiting + HTTP status parsing in WHMCS error handler
- Portal: Took main's UI implementations
- Deleted: TV page, SignupForm, ServicesGrid (as per main)
- Added whmcsRegistrationUrl to field-maps.ts (was missing after file consolidation)

TODO post-merge:
- Refactor order-fulfillment-orchestrator.service.ts to use buildTransactionSteps abstraction
- Fix ESLint errors from main's code (skipped pre-commit for merge)
This commit is contained in:
barsa 2026-02-03 16:12:05 +09:00
commit ff9ee10860
91 changed files with 10565 additions and 4277 deletions

View File

@ -0,0 +1,85 @@
#!/usr/bin/env node
/**
* Quick script to check SIM status in Freebit
* Usage: node scripts/check-sim-status.mjs <phone_number>
*/
const account = process.argv[2] || '02000002470010';
const FREEBIT_BASE_URL = 'https://i1-q.mvno.net/emptool/api';
const FREEBIT_OEM_ID = 'PASI';
const FREEBIT_OEM_KEY = '6Au3o7wrQNR07JxFHPmf0YfFqN9a31t5';
async function getAuthKey() {
const request = {
oemId: FREEBIT_OEM_ID,
oemKey: FREEBIT_OEM_KEY,
};
const response = await fetch(`${FREEBIT_BASE_URL}/authOem/`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `json=${JSON.stringify(request)}`,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.resultCode !== 100 && data.resultCode !== '100') {
throw new Error(`Auth failed: ${data.status?.message || JSON.stringify(data)}`);
}
return data.authKey;
}
async function getTrafficInfo(authKey, account) {
const request = { authKey, account };
const response = await fetch(`${FREEBIT_BASE_URL}/mvno/getTrafficInfo/`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `json=${JSON.stringify(request)}`,
});
return response.json();
}
async function getAccountDetails(authKey, account) {
const request = {
authKey,
version: '2',
requestDatas: [{ kind: 'MVNO', account }],
};
const response = await fetch(`${FREEBIT_BASE_URL}/master/getAcnt/`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `json=${JSON.stringify(request)}`,
});
return response.json();
}
async function main() {
console.log(`\n🔍 Checking SIM status for: ${account}\n`);
try {
const authKey = await getAuthKey();
console.log('✓ Authenticated with Freebit\n');
// Try getTrafficInfo first (simpler)
console.log('--- Traffic Info (/mvno/getTrafficInfo/) ---');
const trafficInfo = await getTrafficInfo(authKey, account);
console.log(JSON.stringify(trafficInfo, null, 2));
// Try getAcnt for full details
console.log('\n--- Account Details (/master/getAcnt/) ---');
const details = await getAccountDetails(authKey, account);
console.log(JSON.stringify(details, null, 2));
} catch (error) {
console.error('❌ Error:', error.message);
}
}
main();

View File

@ -75,8 +75,15 @@ log "Starting Node runtime watcher ($RUN_SCRIPT)..."
pnpm run -s "$RUN_SCRIPT" &
PID_NODE=$!
# If any process exits, stop the rest
wait -n "$PID_BUILD" "$PID_ALIAS" "$PID_NODE"
exit_code=$?
log "A dev process exited (code=$exit_code). Shutting down."
exit "$exit_code"
# If any process exits, stop the rest (compatible with bash 3.2)
while true; do
for pid in "$PID_BUILD" "$PID_ALIAS" "$PID_NODE"; do
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid" 2>/dev/null || true
exit_code=$?
log "A dev process exited (code=$exit_code). Shutting down."
exit "$exit_code"
fi
done
sleep 1
done

View File

@ -0,0 +1,90 @@
Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Response Status,Error,Additional Info
2026-01-31T02:21:03.485Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T02:21:07.599Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:11:11.315Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:11:15.556Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:11:53.182Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:32:18.526Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:32:22.394Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:32:37.351Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:32:41.487Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:48:41.057Z,/mvno/changePlan/,POST,02000331144508,02000331144508,"{""account"":""02000331144508"",""planCode"":""PASI_5G"",""runTime"":""20260301""}",Error: 211,API Error: NG,API Error: NG
2026-01-31T04:49:40.396Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:49:44.170Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:50:51.053Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T04:50:56.134Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T05:04:11.957Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T05:04:16.274Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T05:11:55.749Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T05:11:59.557Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T05:18:00.675Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T05:18:06.042Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T08:37:08.201Z,/master/addSpec/,POST,02000331144508,02000331144508,"{""account"":""02000331144508"",""quota"":1024000}",Error: 200,API Error: Bad Request,API Error: Bad Request
2026-01-31T08:45:14.336Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T08:45:18.452Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T08:45:40.760Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T08:45:47.572Z,/mvno/getTrafficInfo/,POST,02000331144508,02000331144508,"{""account"":""02000331144508""}",Error: 210,API Error: NG,API Error: NG
2026-01-31T08:49:32.767Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T08:49:32.948Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T08:50:04.739Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T08:50:05.899Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T08:55:27.913Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T08:55:28.280Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T08:55:39.246Z,/master/addSpec/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""quota"":1024000}",Error: 200,API Error: Bad Request,API Error: Bad Request
2026-01-31T09:03:45.084Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:03:45.276Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:04:02.612Z,/master/addSpec/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""quota"":1000,""kind"":""MVNO""}",Success,,OK
2026-01-31T09:12:19.280Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:12:19.508Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:12:25.347Z,/mvno/changePlan/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""planCode"":""PASI_10G"",""runTime"":""20260301""}",Success,,OK
2026-01-31T09:13:15.309Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:13:15.522Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:21:56.856Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:21:57.041Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:23:40.211Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:24:26.592Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:24:26.830Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:24:49.713Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:24:49.910Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:25:40.613Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:25:53.426Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:26:05.126Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:26:18.482Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-01-31T09:26:57.215Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T01:48:36.804Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T01:48:37.013Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T01:49:41.283Z,/mvno/changePlan/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""planCode"":""PASI_10G"",""runTime"":""20260301""}",Success,,OK
2026-02-02T01:50:58.940Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T01:50:59.121Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T01:51:07.911Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:49:01.626Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:49:01.781Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:49:04.551Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request
2026-02-02T02:49:04.804Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:52:39.440Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:52:39.696Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:52:43.402Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request
2026-02-02T02:52:43.557Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:52:50.419Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request
2026-02-02T02:52:50.595Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:52:58.616Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:52:58.762Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T02:53:01.434Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request
2026-02-02T02:53:01.580Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T03:00:20.821Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T03:00:21.068Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T03:00:25.799Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request
2026-02-02T03:00:26.012Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T04:14:20.988Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T04:14:21.197Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T04:14:23.599Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Error: 204,API Error: Bad Request,API Error: Bad Request
2026-02-02T04:14:23.805Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T04:17:24.519Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T04:17:24.698Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T04:27:46.948Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T04:27:47.130Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-02T04:27:59.150Z,/mvno/contractline/change/,POST,02000215161148,02000215161148,"{""account"":""02000215161148"",""contractLine"":""5G"",""eid"":""89033023426200000000006103081142""}",Success,,OK
2026-02-03T02:22:24.012Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-03T02:22:24.263Z,/mvno/getTrafficInfo/,POST,02000215161148,02000215161148,"{""account"":""02000215161148""}",Success,,OK
2026-02-03T02:44:57.675Z,/mvno/semiblack/addAcnt/,POST,02000002470010,02000002470010,"{""createType"":""new"",""account"":""02000002470010"",""productNumber"":""PT0220024700100"",""planCode"":""PASI_5G"",""shipDate"":""20260203"",""mnp"":{""method"":""10""},""globalIp"":""20"",""aladinOperated"":""20""}",Success,,OK
2026-02-03T02:55:57.379Z,/mvno/semiblack/addAcnt/,POST,07000240050,07000240050,"{""createType"":""new"",""account"":""07000240050"",""productNumber"":""PT0270002400500"",""planCode"":""PASI_10G"",""shipDate"":""20260203"",""mnp"":{""method"":""10""},""globalIp"":""20"",""aladinOperated"":""20""}",Success,,OK
1 Timestamp API Endpoint API Method Phone Number SIM Identifier Request Payload Response Status Error Additional Info
2 2026-01-31T02:21:03.485Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
3 2026-01-31T02:21:07.599Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
4 2026-01-31T04:11:11.315Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
5 2026-01-31T04:11:15.556Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
6 2026-01-31T04:11:53.182Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
7 2026-01-31T04:32:18.526Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
8 2026-01-31T04:32:22.394Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
9 2026-01-31T04:32:37.351Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
10 2026-01-31T04:32:41.487Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
11 2026-01-31T04:48:41.057Z /mvno/changePlan/ POST 02000331144508 02000331144508 {"account":"02000331144508","planCode":"PASI_5G","runTime":"20260301"} Error: 211 API Error: NG API Error: NG
12 2026-01-31T04:49:40.396Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
13 2026-01-31T04:49:44.170Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
14 2026-01-31T04:50:51.053Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
15 2026-01-31T04:50:56.134Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
16 2026-01-31T05:04:11.957Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
17 2026-01-31T05:04:16.274Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
18 2026-01-31T05:11:55.749Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
19 2026-01-31T05:11:59.557Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
20 2026-01-31T05:18:00.675Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
21 2026-01-31T05:18:06.042Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
22 2026-01-31T08:37:08.201Z /master/addSpec/ POST 02000331144508 02000331144508 {"account":"02000331144508","quota":1024000} Error: 200 API Error: Bad Request API Error: Bad Request
23 2026-01-31T08:45:14.336Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
24 2026-01-31T08:45:18.452Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
25 2026-01-31T08:45:40.760Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
26 2026-01-31T08:45:47.572Z /mvno/getTrafficInfo/ POST 02000331144508 02000331144508 {"account":"02000331144508"} Error: 210 API Error: NG API Error: NG
27 2026-01-31T08:49:32.767Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
28 2026-01-31T08:49:32.948Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
29 2026-01-31T08:50:04.739Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
30 2026-01-31T08:50:05.899Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
31 2026-01-31T08:55:27.913Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
32 2026-01-31T08:55:28.280Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
33 2026-01-31T08:55:39.246Z /master/addSpec/ POST 02000215161148 02000215161148 {"account":"02000215161148","quota":1024000} Error: 200 API Error: Bad Request API Error: Bad Request
34 2026-01-31T09:03:45.084Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
35 2026-01-31T09:03:45.276Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
36 2026-01-31T09:04:02.612Z /master/addSpec/ POST 02000215161148 02000215161148 {"account":"02000215161148","quota":1000,"kind":"MVNO"} Success OK
37 2026-01-31T09:12:19.280Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
38 2026-01-31T09:12:19.508Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
39 2026-01-31T09:12:25.347Z /mvno/changePlan/ POST 02000215161148 02000215161148 {"account":"02000215161148","planCode":"PASI_10G","runTime":"20260301"} Success OK
40 2026-01-31T09:13:15.309Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
41 2026-01-31T09:13:15.522Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
42 2026-01-31T09:21:56.856Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
43 2026-01-31T09:21:57.041Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
44 2026-01-31T09:23:40.211Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
45 2026-01-31T09:24:26.592Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
46 2026-01-31T09:24:26.830Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
47 2026-01-31T09:24:49.713Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
48 2026-01-31T09:24:49.910Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
49 2026-01-31T09:25:40.613Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
50 2026-01-31T09:25:53.426Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
51 2026-01-31T09:26:05.126Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
52 2026-01-31T09:26:18.482Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
53 2026-01-31T09:26:57.215Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
54 2026-02-02T01:48:36.804Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
55 2026-02-02T01:48:37.013Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
56 2026-02-02T01:49:41.283Z /mvno/changePlan/ POST 02000215161148 02000215161148 {"account":"02000215161148","planCode":"PASI_10G","runTime":"20260301"} Success OK
57 2026-02-02T01:50:58.940Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
58 2026-02-02T01:50:59.121Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
59 2026-02-02T01:51:07.911Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
60 2026-02-02T02:49:01.626Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
61 2026-02-02T02:49:01.781Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
62 2026-02-02T02:49:04.551Z /mvno/contractline/change/ POST 02000215161148 02000215161148 {"account":"02000215161148","contractLine":"5G","eid":"89033023426200000000006103081142"} Error: 204 API Error: Bad Request API Error: Bad Request
63 2026-02-02T02:49:04.804Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
64 2026-02-02T02:52:39.440Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
65 2026-02-02T02:52:39.696Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
66 2026-02-02T02:52:43.402Z /mvno/contractline/change/ POST 02000215161148 02000215161148 {"account":"02000215161148","contractLine":"5G","eid":"89033023426200000000006103081142"} Error: 204 API Error: Bad Request API Error: Bad Request
67 2026-02-02T02:52:43.557Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
68 2026-02-02T02:52:50.419Z /mvno/contractline/change/ POST 02000215161148 02000215161148 {"account":"02000215161148","contractLine":"5G","eid":"89033023426200000000006103081142"} Error: 204 API Error: Bad Request API Error: Bad Request
69 2026-02-02T02:52:50.595Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
70 2026-02-02T02:52:58.616Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
71 2026-02-02T02:52:58.762Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
72 2026-02-02T02:53:01.434Z /mvno/contractline/change/ POST 02000215161148 02000215161148 {"account":"02000215161148","contractLine":"5G","eid":"89033023426200000000006103081142"} Error: 204 API Error: Bad Request API Error: Bad Request
73 2026-02-02T02:53:01.580Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
74 2026-02-02T03:00:20.821Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
75 2026-02-02T03:00:21.068Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
76 2026-02-02T03:00:25.799Z /mvno/contractline/change/ POST 02000215161148 02000215161148 {"account":"02000215161148","contractLine":"5G","eid":"89033023426200000000006103081142"} Error: 204 API Error: Bad Request API Error: Bad Request
77 2026-02-02T03:00:26.012Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
78 2026-02-02T04:14:20.988Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
79 2026-02-02T04:14:21.197Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
80 2026-02-02T04:14:23.599Z /mvno/contractline/change/ POST 02000215161148 02000215161148 {"account":"02000215161148","contractLine":"5G","eid":"89033023426200000000006103081142"} Error: 204 API Error: Bad Request API Error: Bad Request
81 2026-02-02T04:14:23.805Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
82 2026-02-02T04:17:24.519Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
83 2026-02-02T04:17:24.698Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
84 2026-02-02T04:27:46.948Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
85 2026-02-02T04:27:47.130Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
86 2026-02-02T04:27:59.150Z /mvno/contractline/change/ POST 02000215161148 02000215161148 {"account":"02000215161148","contractLine":"5G","eid":"89033023426200000000006103081142"} Success OK
87 2026-02-03T02:22:24.012Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
88 2026-02-03T02:22:24.263Z /mvno/getTrafficInfo/ POST 02000215161148 02000215161148 {"account":"02000215161148"} Success OK
89 2026-02-03T02:44:57.675Z /mvno/semiblack/addAcnt/ POST 02000002470010 02000002470010 {"createType":"new","account":"02000002470010","productNumber":"PT0220024700100","planCode":"PASI_5G","shipDate":"20260203","mnp":{"method":"10"},"globalIp":"20","aladinOperated":"20"} Success OK
90 2026-02-03T02:55:57.379Z /mvno/semiblack/addAcnt/ POST 07000240050 07000240050 {"createType":"new","account":"07000240050","productNumber":"PT0270002400500","planCode":"PASI_10G","shipDate":"20260203","mnp":{"method":"10"},"globalIp":"20","aladinOperated":"20"} Success OK

View File

@ -11,10 +11,13 @@ 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 { FreebitErrorHandlerService } from "./services/freebit-error-handler.service.js";
import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.module.js";
import { FreebitTestTrackerService } from "./services/freebit-test-tracker.service.js";
import { FreebitAccountRegistrationService } from "./services/freebit-account-registration.service.js";
import { FreebitVoiceOptionsService } from "./services/freebit-voice-options.service.js";
import { FreebitSemiBlackService } from "./services/freebit-semiblack.service.js";
@Module({
imports: [VoiceOptionsModule],
imports: [],
providers: [
// Core services
FreebitErrorHandlerService,
@ -22,6 +25,7 @@ import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.mo
FreebitAuthService,
FreebitMapperService,
FreebitRateLimiterService,
FreebitTestTrackerService,
// Specialized operation services
FreebitAccountService,
FreebitUsageService,
@ -29,6 +33,10 @@ import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.mo
FreebitVoiceService,
FreebitCancellationService,
FreebitEsimService,
// Physical SIM activation services (PA05-18 + PA02-01 + PA05-05)
FreebitSemiBlackService,
FreebitAccountRegistrationService,
FreebitVoiceOptionsService,
// Facade (delegates to specialized services, handles account normalization)
FreebitFacade,
],
@ -44,6 +52,12 @@ import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.mo
FreebitVoiceService,
FreebitCancellationService,
FreebitEsimService,
// Physical SIM activation services (PA05-18 + PA02-01 + PA05-05)
FreebitSemiBlackService,
FreebitAccountRegistrationService,
FreebitVoiceOptionsService,
// Rate limiter (needed by SimController)
FreebitRateLimiterService,
],
})
export class FreebitModule {}

View File

@ -0,0 +1,151 @@
/**
* Freebit Account Registration Service (PA02-01)
*
* Handles MVNO account registration via the Freebit PA02-01 API.
* This is the first step in Physical SIM activation - registering
* the phone number (account) in Freebit's system.
*
* @see docs/freebit-apis/PA02-01-account-registration.md
*/
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { FreebitClientService } from "./freebit-client.service.js";
/**
* PA02-01 Account Registration parameters
*/
export interface AccountRegistrationParams {
/** MSISDN (phone number) - 11-14 digits */
account: string;
/** Freebit plan code (e.g., "PASI_5G") */
planCode: string;
/** Create type: "new" for new account, "add" for existing (use "add" after PA05-18) */
createType?: "new" | "add";
/** Global IP assignment: "10" = valid (deprecated), "20" = invalid */
globalIp?: "10" | "20";
/** Priority flag: "10" = valid, "20" = invalid (default) */
priorityFlag?: "10" | "20";
}
/**
* PA02-01 Request payload structure
*/
interface FreebitAccountRegistrationRequest {
createType: "new" | "add";
requestDatas: Array<{
kind: "MVNO";
account: string;
planCode: string;
globalIp?: string;
priorityFlag?: string;
}>;
}
/**
* PA02-01 Response structure
*/
interface FreebitAccountRegistrationResponse {
resultCode: string;
status?: {
message?: string;
statusCode?: string | number;
};
responseDatas?: Array<{
kind: string;
account: string;
ipv4?: string;
ipv6?: string;
resultCode: string;
}>;
}
@Injectable()
export class FreebitAccountRegistrationService {
constructor(
private readonly client: FreebitClientService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Register a new MVNO account (PA02-01)
*
* This creates a new account in Freebit's system with the specified
* phone number and plan. This must be called before PA05-05 voice options.
*
* Note: Account creation is asynchronous and may take up to 10 minutes.
*
* @param params - Account registration parameters
* @throws BadRequestException if registration fails
*/
async registerAccount(params: AccountRegistrationParams): Promise<void> {
const { account, planCode, createType = "new", globalIp, priorityFlag } = params;
// Validate required parameters
if (!account || account.length < 11 || account.length > 14) {
throw new BadRequestException(
"Invalid phone number (account) for registration - must be 11-14 digits"
);
}
if (!planCode) {
throw new BadRequestException("Plan code is required for account registration");
}
this.logger.log("Starting MVNO account registration (PA02-01)", {
account,
accountLength: account.length,
planCode,
createType,
hasGlobalIp: !!globalIp,
hasPriorityFlag: !!priorityFlag,
});
try {
// Build payload according to PA02-01 documentation
// Note: authKey is added automatically by makeAuthenticatedRequest
const payload: FreebitAccountRegistrationRequest = {
createType,
requestDatas: [
{
kind: "MVNO",
account,
planCode,
...(globalIp && { globalIp }),
...(priorityFlag && { priorityFlag }),
},
],
};
// PA02-01 uses form-urlencoded format with json= parameter
const response = await this.client.makeAuthenticatedRequest<
FreebitAccountRegistrationResponse,
FreebitAccountRegistrationRequest
>("/master/addAcnt/", payload);
// Check response for individual account results
if (response.responseDatas && response.responseDatas.length > 0) {
const accountResult = response.responseDatas[0];
if (accountResult.resultCode !== "100") {
throw new BadRequestException(
`Account registration failed for ${account}: result code ${accountResult.resultCode}`
);
}
}
this.logger.log("MVNO account registration successful (PA02-01)", {
account,
planCode,
});
} catch (error: unknown) {
const message = extractErrorMessage(error);
this.logger.error("MVNO account registration failed (PA02-01)", {
account,
planCode,
error: message,
});
throw new BadRequestException(`Account registration failed: ${message}`);
}
}
}

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,179 +37,243 @@ 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";
const errorDetails = {
url,
resultCode,
statusCode,
statusMessage: responseData.status?.message,
...(isProd ? {} : { fullResponse: JSON.stringify(responseData) }),
};
this.logger.error("Freebit API returned error response", errorDetails);
// Also log to console for visibility in dev
if (!isProd) {
console.error("[FREEBIT ERROR]", JSON.stringify(errorDetails, null, 2));
}
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;
}
}
/**
* Make an authenticated JSON request to Freebit API (for PA05-41)
* Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.)
*/
async makeAuthenticatedJsonRequest<
TResponse extends FreebitResponseBase,
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
const config = this.authService.getConfig();
const authKey = await this.authService.getAuthKey();
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),
});
// Add authKey to the payload for authentication
const requestPayload = { ...payload, authKey };
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,
let attempt = 0;
// Log request details in dev for debugging
const isProd = process.env.NODE_ENV === "production";
if (!isProd) {
console.log("[FREEBIT JSON API REQUEST]", JSON.stringify({
url,
payload: redactForLogs(requestPayload),
}, null, 2));
}
try {
const responseData = await withRetry(
async () => {
attempt += 1;
this.logger.debug("Freebit JSON API request", {
url,
attempt,
maxAttempts: config.retryAttempts,
payload: redactForLogs(requestPayload),
});
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(requestPayload),
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";
const errorDetails = {
url,
resultCode,
statusCode,
message: responseData.status?.message,
...(isProd ? {} : { response: redactForLogs(responseData as unknown) }),
attempt,
};
this.logger.error("Freebit JSON API returned error result code", errorDetails);
// Always log to console in dev for visibility
if (!isProd) {
console.error("[FREEBIT JSON API ERROR]", JSON.stringify(errorDetails, null, 2));
}
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 +330,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

@ -82,6 +82,14 @@ export class FreebitError extends Error {
}
// Specific error codes
if (this.resultCode === "200" || this.statusCode === "200") {
return "Invalid request parameters. The account number or quota value may be incorrect. Please verify the SIM account details.";
}
if (this.resultCode === "210" || this.statusCode === "210") {
return "No traffic data available for this account. This is normal for new SIMs that haven't used any data yet.";
}
if (this.resultCode === "215" || this.statusCode === "215") {
return "Plan change failed. This may be due to: (1) Account has existing scheduled operations, (2) Invalid plan code for this account, (3) Account restrictions. Please check the Freebit Partner Tools for account status or contact support.";
}

View File

@ -75,6 +75,19 @@ export class FreebitMapperService {
throw new Error("No account data in response");
}
// Debug: Log raw voice option fields from API response
this.logger.debug("[FreebitMapper] Raw API voice option fields", {
account: account.account,
voicemail: account.voicemail,
voiceMail: account.voiceMail,
callwaiting: account.callwaiting,
callWaiting: account.callWaiting,
worldwing: account.worldwing,
worldWing: account.worldWing,
talk: account.talk,
sms: account.sms,
});
let simType: "standard" | "nano" | "micro" | "esim" = "standard";
if (account.eid) {
simType = "esim";
@ -83,49 +96,19 @@ export class FreebitMapperService {
}
// Try to get voice options from database first
let voiceMailEnabled = true;
let callWaitingEnabled = true;
let internationalRoamingEnabled = true;
// Default to false - show as disabled unless API confirms enabled
let voiceMailEnabled = false;
let callWaitingEnabled = false;
let internationalRoamingEnabled = false;
let networkType = String(account.networkType ?? account.contractLine ?? "4G");
// Try to load stored options from database first
let storedOptions = null;
if (this.voiceOptionsService) {
try {
const storedOptions = await this.voiceOptionsService.getVoiceOptions(
storedOptions = await this.voiceOptionsService.getVoiceOptions(
String(account.account ?? "")
);
if (storedOptions) {
voiceMailEnabled = storedOptions.voiceMailEnabled;
callWaitingEnabled = storedOptions.callWaitingEnabled;
internationalRoamingEnabled = storedOptions.internationalRoamingEnabled;
networkType = storedOptions.networkType;
this.logger.debug("[FreebitMapper] Loaded voice options from database", {
account: account.account,
options: storedOptions,
});
} else {
// No stored options, check API response
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, true);
callWaitingEnabled = this.parseOptionFlag(
account.callwaiting ?? account.callWaiting,
true
);
internationalRoamingEnabled = this.parseOptionFlag(
account.worldwing ?? account.worldWing,
true
);
this.logger.debug(
"[FreebitMapper] No stored options found, using defaults or API values",
{
account: account.account,
voiceMailEnabled,
callWaitingEnabled,
internationalRoamingEnabled,
}
);
}
} catch (error) {
this.logger.warn("[FreebitMapper] Failed to load voice options from database", {
account: account.account,
@ -134,6 +117,44 @@ export class FreebitMapperService {
}
}
if (storedOptions) {
// Use stored options from database
voiceMailEnabled = storedOptions.voiceMailEnabled;
callWaitingEnabled = storedOptions.callWaitingEnabled;
internationalRoamingEnabled = storedOptions.internationalRoamingEnabled;
networkType = storedOptions.networkType;
this.logger.debug("[FreebitMapper] Loaded voice options from database", {
account: account.account,
options: storedOptions,
});
} else {
// No stored options, parse from API response
// Default to false - disabled unless API explicitly returns 10 (enabled)
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, false);
callWaitingEnabled = this.parseOptionFlag(
account.callwaiting ?? account.callWaiting,
false
);
internationalRoamingEnabled = this.parseOptionFlag(
account.worldwing ?? account.worldWing,
false
);
this.logger.debug(
"[FreebitMapper] No stored options found, using API values (default: disabled)",
{
account: account.account,
rawVoiceMail: account.voicemail ?? account.voiceMail ?? "(not in API response)",
rawCallWaiting: account.callwaiting ?? account.callWaiting ?? "(not in API response)",
rawWorldWing: account.worldwing ?? account.worldWing ?? "(not in API response)",
parsedVoiceMailEnabled: voiceMailEnabled,
parsedCallWaitingEnabled: callWaitingEnabled,
parsedInternationalRoamingEnabled: internationalRoamingEnabled,
}
);
}
// Convert quota from KB to MB if needed
// Freebit API returns quota in KB, but remainingQuotaMb should be in MB
let remainingQuotaMb = 0;
@ -153,6 +174,16 @@ export class FreebitMapperService {
remainingQuotaMb = remainingQuotaKb / 1000;
}
// Log raw account data in dev to debug MSISDN availability
if (process.env.NODE_ENV !== "production") {
console.log("[FREEBIT ACCOUNT DATA]", JSON.stringify({
account: account.account,
msisdn: account.msisdn,
eid: account.eid,
iccid: account.iccid,
}, null, 2));
}
return {
account: String(account.account ?? ""),
status: this.mapSimStatus(String(account.state ?? account.status ?? "pending")),

View File

@ -222,6 +222,45 @@ export class FreebitRateLimiterService {
}
}
/**
* Clear all rate limit timestamps for an account (for testing/debugging)
*/
async clearRateLimitForAccount(account: string): Promise<void> {
const key = this.buildKey(account);
try {
await this.redis.del(key);
this.operationTimestamps.delete(account);
this.logger.log(`Cleared rate limit state for account ${account}`);
} catch (error) {
this.logger.warn("Failed to clear rate limit state", {
account,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Get remaining wait time for a specific operation type (in seconds)
*/
async getRemainingWaitTime(account: string, op: OperationType): Promise<number> {
const entry = await this.getOperationWindow(account);
const now = Date.now();
if (op === "network") {
const voiceWait = entry.voice ? Math.max(0, this.windowMs - (now - entry.voice)) : 0;
const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0;
return Math.ceil(Math.max(voiceWait, planWait) / 1000);
}
if (op === "voice") {
const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0;
const networkWait = entry.network ? Math.max(0, this.windowMs - (now - entry.network)) : 0;
return Math.ceil(Math.max(planWait, networkWait) / 1000);
}
return 0;
}
/**
* Record that an operation was performed for an account.
*/

View File

@ -0,0 +1,216 @@
/**
* Freebit Semi-Black Account Registration Service (PA05-18)
*
* Handles MVNO semi-black () SIM registration via the Freebit PA05-18 API.
* This must be called BEFORE PA02-01 for physical SIMs to create the account
* in Freebit's system.
*
* Semi-black SIMs are pre-provisioned SIMs that need to be registered
* to associate them with a customer account and plan.
*
* Error 210 "アカウント不在エラー" on PA02-01 indicates that PA05-18
* was not called first.
*/
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { FreebitClientService } from "./freebit-client.service.js";
/**
* PA05-18 Semi-Black Account Registration parameters
*/
export interface SemiBlackRegistrationParams {
/** MSISDN (phone number) - 11-14 digits */
account: string;
/** Manufacturing number (productNumber) - 15 chars, e.g., AXxxxxxxxxxxxxx */
productNumber: string;
/** Freebit plan code (e.g., "PASI_5G") */
planCode: string;
/** Ship date in YYYYMMDD format (defaults to today) */
shipDate?: string;
/** Create type: "new" for new master account, "add" for existing */
createType?: "new" | "add";
/** Global IP assignment: "20" = disabled (default) */
globalIp?: "20";
/** ALADIN operation flag: "10" = operated, "20" = not operated (default) */
aladinOperated?: "10" | "20";
/** Sales channel code (optional) */
deliveryCode?: string;
}
/**
* PA05-18 Request payload structure
*/
interface FreebitSemiBlackRequest {
authKey: string;
createType: "new" | "add";
account: string;
productNumber: string;
planCode: string;
shipDate: string;
mnp: {
method: "10"; // "10" = Semi-black SIM
};
globalIp?: string;
aladinOperated?: string;
deliveryCode?: string;
}
/**
* PA05-18 Response structure
*/
interface FreebitSemiBlackResponse {
resultCode: number;
status: {
message: string;
statusCode: number;
};
}
@Injectable()
export class FreebitSemiBlackService {
constructor(
private readonly client: FreebitClientService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Register a semi-black SIM account (PA05-18)
*
* This registers a pre-provisioned (semi-black) physical SIM in Freebit's
* system. Must be called BEFORE PA02-01 account registration.
*
* @param params - Semi-black registration parameters
* @throws BadRequestException if registration fails
*/
async registerSemiBlackAccount(params: SemiBlackRegistrationParams): Promise<void> {
const {
account,
productNumber,
planCode,
shipDate,
createType = "new",
globalIp = "20",
aladinOperated = "20",
deliveryCode,
} = params;
// Validate phone number
if (!account || account.length < 11 || account.length > 14) {
throw new BadRequestException(
"Invalid phone number (account) for semi-black registration - must be 11-14 digits"
);
}
// Validate product number (manufacturing number)
if (!productNumber || productNumber.length !== 15) {
throw new BadRequestException(
"Invalid product number for semi-black registration - must be 15 characters (e.g., AXxxxxxxxxxxxxx)"
);
}
if (!planCode) {
throw new BadRequestException("Plan code is required for semi-black registration");
}
// Default to today's date if not provided
const effectiveShipDate = shipDate ?? this.formatTodayAsYYYYMMDD();
this.logger.log("Starting semi-black SIM registration (PA05-18)", {
account,
productNumber,
planCode,
shipDate: effectiveShipDate,
createType,
});
try {
const payload: Omit<FreebitSemiBlackRequest, "authKey"> = {
createType,
account,
productNumber,
planCode,
shipDate: effectiveShipDate,
mnp: {
method: "10", // Semi-black SIM method
},
globalIp,
aladinOperated,
...(deliveryCode && { deliveryCode }),
};
// FreebitClientService validates resultCode === "100" before returning
await this.client.makeAuthenticatedRequest<
FreebitSemiBlackResponse,
Omit<FreebitSemiBlackRequest, "authKey">
>("/mvno/semiblack/addAcnt/", payload);
this.logger.log("Semi-black SIM registration successful (PA05-18)", {
account,
productNumber,
planCode,
});
} catch (error: unknown) {
// Re-throw BadRequestException as-is
if (error instanceof BadRequestException) {
throw error;
}
const message = extractErrorMessage(error);
this.logger.error("Semi-black registration failed (PA05-18)", {
account,
productNumber,
planCode,
error: message,
});
throw new BadRequestException(`Semi-black registration failed: ${message}`);
}
}
/**
* Format today's date as YYYYMMDD
*/
private formatTodayAsYYYYMMDD(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
/**
* Get human-readable error message for PA05-18 error codes
*/
private getErrorMessage(code: number, defaultMessage?: string): string {
const errorMessages: Record<number, string> = {
201: "Invalid account/phone number parameter",
202: "Invalid master password",
204: "Invalid parameter",
205: "Authentication key error",
208: "Account already exists (duplicate)",
210: "Master account not found",
211: "Account status does not allow this operation",
215: "Invalid plan code",
228: "Invalid authentication key",
230: "Account is in async processing queue",
231: "Invalid global IP parameter",
232: "Plan not found",
266: "Invalid product number (manufacturing number)",
269: "Invalid representative number",
274: "Invalid delivery code",
275: "No phone number stock for representative number",
276: "Invalid ship date",
279: "Invalid create type",
284: "Representative number is locked",
287: "Representative number does not exist or is unavailable",
288: "Product number does not exist, is already used, or not stocked",
289: "SIM type does not match representative number type",
306: "Invalid MNP method",
313: "MNP reservation expires within grace period",
900: "Unexpected system error",
};
return errorMessages[code] ?? defaultMessage ?? "Unknown error";
}
}

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

@ -58,17 +58,36 @@ export class FreebitUsageService {
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
): Promise<void> {
try {
const quotaKb = Math.round(quotaMb * 1024);
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
// Note: Freebit addSpec API expects quota in MB (not KB as previously thought)
// Also requires 'kind' field for MVNO operations
const baseRequest = {
account,
quota: quotaKb,
...(options.campaignCode !== undefined && { quotaCode: options.campaignCode }),
...(options.expiryDate !== undefined && { expire: options.expiryDate }),
quota: quotaMb, // MB units for addSpec
kind: "MVNO", // Required for MVNO operations
quotaCode: options.campaignCode,
expire: options.expiryDate,
};
const scheduled = !!options.scheduledAt;
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest;
// For scheduled operations, use KB and add runTime
const request = scheduled
? { ...baseRequest, quota: Math.round(quotaMb * 1024), runTime: options.scheduledAt }
: baseRequest;
// Log the request details for debugging
this.logger.log(`Freebit addSpec request details`, {
endpoint,
account,
quotaMb,
quotaUnit: scheduled ? "KB" : "MB",
kind: "MVNO",
quotaCode: options.campaignCode || "(none)",
expire: options.expiryDate || "(none)",
scheduled,
requestPayload: JSON.stringify(request),
});
await this.client.makeAuthenticatedRequest<FreebitTopUpResponse, typeof request>(
endpoint,
@ -79,7 +98,6 @@ export class FreebitUsageService {
account,
endpoint,
quotaMb,
quotaKb,
scheduled,
});
} catch (error) {

View File

@ -0,0 +1,213 @@
/**
* Freebit Voice Options Service (PA05-05)
*
* Handles MVNO voice option registration via the Freebit PA05-05 API.
* This is called after PA02-01 account registration to configure
* voice features like VoiceMail, CallWaiting, WorldCall, etc.
*
* @see docs/freebit-apis/PA05-05-mvno-voice-option-registration.md
*/
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { FreebitClientService } from "./freebit-client.service.js";
/**
* Identity data required for voice option registration
*/
export interface VoiceOptionIdentityData {
/** Last name in Kanji (UTF-8, max 50 chars) */
lastnameKanji: string;
/** First name in Kanji (UTF-8, max 50 chars) */
firstnameKanji: string;
/** Last name in Katakana (full-width, max 50 chars) */
lastnameKana: string;
/** First name in Katakana (full-width, max 50 chars) */
firstnameKana: string;
/** Gender: "M" = Male, "F" = Female */
gender: "M" | "F";
/** Birthday in YYYYMMDD format */
birthday: string;
}
/**
* PA05-05 Voice Option Registration parameters
*/
export interface VoiceOptionRegistrationParams {
/** MSISDN (phone number) - must be already registered via PA02-01 */
account: string;
/** Enable VoiceMail service */
voiceMailEnabled: boolean;
/** Enable CallWaiting (Catch Phone) service */
callWaitingEnabled: boolean;
/** Customer identity data (required) */
identificationData: VoiceOptionIdentityData;
/** WorldCall credit limit in yen (default: 5000) */
worldCallCreditLimit?: string;
}
/**
* PA05-05 Request payload structure
*/
interface FreebitVoiceOptionRequest {
account: string;
userConfirmed: "10" | "20";
aladinOperated: "10" | "20";
talkOption: {
voiceMail: "10" | "20";
callWaiting: "10" | "20";
callTransfer?: "10" | "20";
callTransferNoId?: "10" | "20";
worldCall: "10" | "20";
worldCallCreditLimit?: string;
worldWing: "10" | "20";
worldWingCreditLimit?: string;
};
identificationData: {
lastnameKanji: string;
firstnameKanji: string;
lastnameKana: string;
firstnameKana: string;
gender: string;
birthday: string;
};
}
/**
* PA05-05 Response structure
*/
interface FreebitVoiceOptionResponse {
resultCode: string;
status?: {
message?: string;
statusCode?: string | number;
};
}
@Injectable()
export class FreebitVoiceOptionsService {
constructor(
private readonly client: FreebitClientService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Register voice options for an MVNO account (PA05-05)
*
* This configures voice features for a phone number that was previously
* registered via PA02-01. Must be called after account registration.
*
* Default settings applied:
* - WorldCall: Always enabled
* - WorldWing: Always disabled
*
* @param params - Voice option registration parameters
* @throws BadRequestException if registration fails
*/
async registerVoiceOptions(params: VoiceOptionRegistrationParams): Promise<void> {
const {
account,
voiceMailEnabled,
callWaitingEnabled,
identificationData,
worldCallCreditLimit = "5000",
} = params;
// Validate required parameters
if (!account || account.length < 11) {
throw new BadRequestException("Invalid phone number (account) for voice option registration");
}
if (!identificationData) {
throw new BadRequestException("Identity data is required for voice option registration");
}
// Validate identity data
if (!identificationData.lastnameKanji || !identificationData.firstnameKanji) {
throw new BadRequestException("Name (Kanji) is required for voice option registration");
}
if (!identificationData.lastnameKana || !identificationData.firstnameKana) {
throw new BadRequestException("Name (Kana) is required for voice option registration");
}
if (!identificationData.gender || !["M", "F"].includes(identificationData.gender)) {
throw new BadRequestException("Valid gender (M/F) is required for voice option registration");
}
if (!identificationData.birthday || !/^\d{8}$/.test(identificationData.birthday)) {
throw new BadRequestException(
"Birthday in YYYYMMDD format is required for voice option registration"
);
}
this.logger.log("Starting voice option registration (PA05-05)", {
account,
voiceMailEnabled,
callWaitingEnabled,
worldCallCreditLimit,
hasIdentityData: !!identificationData,
});
try {
// Build payload according to PA05-05 documentation
// Note: authKey is added automatically by makeAuthenticatedRequest
const payload: FreebitVoiceOptionRequest = {
account,
userConfirmed: "10", // Always confirmed
aladinOperated: "10", // ALADIN operated
talkOption: {
voiceMail: voiceMailEnabled ? "10" : "20",
callWaiting: callWaitingEnabled ? "10" : "20",
worldCall: "10", // Always enabled per requirements
worldCallCreditLimit,
worldWing: "20", // Always disabled per requirements
},
identificationData: {
lastnameKanji: identificationData.lastnameKanji,
firstnameKanji: identificationData.firstnameKanji,
lastnameKana: identificationData.lastnameKana,
firstnameKana: identificationData.firstnameKana,
gender: identificationData.gender,
birthday: identificationData.birthday,
},
};
// PA05-05 uses form-urlencoded format with json= parameter
await this.client.makeAuthenticatedRequest<
FreebitVoiceOptionResponse,
FreebitVoiceOptionRequest
>("/mvno/talkoption/addOrder/", payload);
this.logger.log("Voice option registration successful (PA05-05)", {
account,
voiceMailEnabled,
callWaitingEnabled,
});
} catch (error: unknown) {
const message = extractErrorMessage(error);
this.logger.error("Voice option registration failed (PA05-05)", {
account,
error: message,
});
throw new BadRequestException(`Voice option registration failed: ${message}`);
}
}
/**
* Format birthday from Date or ISO string to YYYYMMDD
*/
formatBirthday(date: Date | string | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
if (isNaN(d.getTime())) return "";
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
}

View File

@ -207,6 +207,8 @@ export class FreebitVoiceService {
async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise<void> {
let eid: string | undefined;
let productNumber: string | undefined;
// PA05-38 may require MSISDN (phone number) instead of internal account ID
let apiAccount = account;
try {
try {
@ -216,10 +218,16 @@ export class FreebitVoiceService {
} else if (details.iccid) {
productNumber = details.iccid;
}
// Use MSISDN if available, as PA05-38 expects phone number format
if (details.msisdn && details.msisdn.length >= 10) {
apiAccount = details.msisdn;
}
this.logger.debug(`Resolved SIM identifiers for contract line change`, {
account,
originalAccount: account,
apiAccount,
eid,
productNumber,
msisdn: details.msisdn,
currentNetworkType: details.networkType,
});
if (details.networkType?.toUpperCase() === networkType.toUpperCase()) {
@ -241,19 +249,21 @@ export class FreebitVoiceService {
await this.rateLimiter.executeWithSpacing(account, "network", async () => {
const request: Omit<FreebitContractLineChangeRequest, "authKey"> = {
account,
account: apiAccount,
contractLine: networkType,
...(eid ? { eid } : {}),
...(productNumber ? { productNumber } : {}),
};
this.logger.debug(`Updating network type via PA05-38 for account ${account}`, {
account,
this.logger.debug(`Updating network type via PA05-38`, {
originalAccount: account,
apiAccount,
networkType,
request,
});
const response = await this.client.makeAuthenticatedJsonRequest<
// PA05-38 uses form-urlencoded format (json={...}), not pure JSON
const response = await this.client.makeAuthenticatedRequest<
FreebitContractLineChangeResponse,
typeof request
>("/mvno/contractline/change/", request);

View File

@ -8,3 +8,7 @@ export { FreebitPlanService } from "./freebit-plan.service.js";
export { FreebitVoiceService } from "./freebit-voice.service.js";
export { FreebitCancellationService } from "./freebit-cancellation.service.js";
export { FreebitEsimService, type EsimActivationParams } from "./freebit-esim.service.js";
export {
FreebitSemiBlackService,
type SemiBlackRegistrationParams,
} from "./freebit-semiblack.service.js";

View File

@ -133,6 +133,7 @@ export const OPPORTUNITY_FIELDS = {
// Portal integration fields
source: "Opportunity_Source__c",
whmcsServiceId: "WHMCS_Service_ID__c",
whmcsRegistrationUrl: "WH_Registeration__c",
} as const;
export type OpportunityFieldKey = keyof typeof OPPORTUNITY_FIELDS;

View File

@ -134,6 +134,11 @@ export class OrderCdcSubscriber implements OnModuleInit {
await this.handleActivationStatusChange(payload, orderId);
}
// Check for provisioning trigger (Status change to "Approved")
if (payload && changedFields.has("Status")) {
await this.handleStatusApprovedChange(payload, orderId);
}
// Cache invalidation - only for customer-facing field changes
const hasCustomerFacingChange = this.hasCustomerFacingChanges(changedFields);
@ -216,6 +221,60 @@ export class OrderCdcSubscriber implements OnModuleInit {
}
}
/**
* Handle order status changes to "Approved"
*
* Enqueues a provisioning job when Status changes to "Approved".
* The provisioning processor will fetch the full order from Salesforce
* and validate the conditions (SIM_Type__c, Assign_Physical_SIM__c, etc.)
*
* NOTE: We cannot check SIM_Type__c or Assign_Physical_SIM__c from the CDC payload
* because CDC only includes CHANGED fields. If only Status was updated, those fields
* will be null in the payload even though they have values on the record.
*
* The processor handles:
* - Physical SIM: Status="Approved" + SIM_Type="Physical SIM" + Assigned_Physical_SIM set
* - Standard: Activation_Status__c="Activating"
* - Idempotency via WHMCS_Order_ID__c check
*/
private async handleStatusApprovedChange(
payload: Record<string, unknown>,
orderId: string
): Promise<void> {
const status = extractStringField(payload, ["Status"]);
// Only trigger when status changes to "Approved"
if (status !== "Approved") {
return;
}
// Note: We intentionally do NOT check SIM_Type__c or Assign_Physical_SIM__c here
// because CDC payloads only contain changed fields. The provisioning processor
// will fetch the full order and validate all conditions.
this.logger.log("Enqueuing provisioning job for order status change to Approved", {
orderId,
status,
});
try {
await this.provisioningQueue.enqueue({
sfOrderId: orderId,
idempotencyKey: `cdc-status-approved-${Date.now()}-${orderId}`,
correlationId: `cdc-status-approved-${orderId}`,
});
this.logger.log("Successfully enqueued provisioning job for Approved status", {
orderId,
});
} catch (error) {
this.logger.error("Failed to enqueue provisioning job for Approved status", {
orderId,
error: error instanceof Error ? error.message : String(error),
});
}
}
// ─────────────────────────────────────────────────────────────────────────────
// OrderItem CDC Handler
// ─────────────────────────────────────────────────────────────────────────────

View File

@ -154,21 +154,30 @@ export class SalesforceFacade implements OnModuleInit {
});
}
const result = (await this.connection.query(
`SELECT Id, Status, Type, Activation_Status__c, WHMCS_Order_ID__c,
Activation_Error_Code__c, Activation_Error_Message__c,
AccountId, Account.Name
FROM Order
WHERE Id = '${orderId}'
LIMIT 1`,
{ label: "orders:integration:getOrder" }
)) as { records: SalesforceOrderRecord[]; totalSize: number };
const soql = `
SELECT Id, Status, Type, Activation_Status__c, WHMCS_Order_ID__c,
Activation_Error_Code__c, Activation_Error_Message__c,
AccountId, Account.Name, SIM_Type__c, Assign_Physical_SIM__c
FROM Order
WHERE Id = '${orderId}'
LIMIT 1
`.trim();
const result = (await this.connection.query(soql, {
label: "orders:integration:getOrder",
})) as { records: SalesforceOrderRecord[]; totalSize: number };
return result.records?.[0] || null;
} catch (error) {
// Temporary: Raw console log to see full error
console.error(
">>> SALESFORCE getOrder ERROR >>>",
JSON.stringify(error, Object.getOwnPropertyNames(error as object), 2)
);
this.logger.error("Failed to get order from Salesforce", {
orderId,
error: extractErrorMessage(error),
errorDetails: error instanceof Error ? { name: error.name, stack: error.stack } : error,
});
throw error;
}

View File

@ -15,6 +15,7 @@ import { OpportunityQueryService } from "./services/opportunity/opportunity-quer
import { OpportunityCancellationService } from "./services/opportunity/opportunity-cancellation.service.js";
import { OpportunityMutationService } from "./services/opportunity/opportunity-mutation.service.js";
import { SalesforceErrorHandlerService } from "./services/salesforce-error-handler.service.js";
import { SalesforceSIMInventoryService } from "./services/salesforce-sim-inventory.service.js";
@Module({
imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule],
@ -31,6 +32,7 @@ import { SalesforceErrorHandlerService } from "./services/salesforce-error-handl
// Opportunity facade (depends on decomposed services)
SalesforceOpportunityService,
OpportunityResolutionService,
SalesforceSIMInventoryService,
SalesforceFacade,
SalesforceReadThrottleGuard,
SalesforceWriteThrottleGuard,
@ -45,6 +47,7 @@ import { SalesforceErrorHandlerService } from "./services/salesforce-error-handl
SalesforceCaseService,
SalesforceOpportunityService,
OpportunityResolutionService,
SalesforceSIMInventoryService,
SalesforceReadThrottleGuard,
SalesforceWriteThrottleGuard,
],

View File

@ -0,0 +1,238 @@
/**
* Salesforce SIM Inventory Integration Service
*
* Manages Physical SIM inventory records in Salesforce.
* - Query SIM_Inventory__c by ID
* - Validate SIM availability status
* - Update SIM status after activation
*
* @see docs/integrations/salesforce/sim-inventory.md
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "./salesforce-connection.service.js";
import { assertSalesforceId } from "../utils/soql.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { SimActivationException } from "@bff/core/exceptions/domain-exceptions.js";
/**
* SIM Inventory Status values in Salesforce
*/
export const SIM_INVENTORY_STATUS = {
AVAILABLE: "Available",
ASSIGNED: "Assigned",
RESERVED: "Reserved",
DEACTIVATED: "Deactivated",
} as const;
export type SimInventoryStatus = (typeof SIM_INVENTORY_STATUS)[keyof typeof SIM_INVENTORY_STATUS];
/**
* SIM Inventory record from Salesforce
*/
export interface SimInventoryRecord {
id: string;
phoneNumber: string;
ptNumber: string;
oemId?: string;
status: SimInventoryStatus;
// Note: ICCID__c and IMSI__c fields don't exist on SIM_Inventory__c in this Salesforce org
}
/**
* Raw Salesforce SIM_Inventory__c response
*/
interface SalesforceSIMInventoryResponse {
records: Array<{
Id: string;
Phone_Number__c?: string | null;
PT_Number__c?: string | null;
OEM_ID__c?: string | null;
Status__c?: string | null;
}>;
}
@Injectable()
export class SalesforceSIMInventoryService {
constructor(
private readonly sf: SalesforceConnection,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Get SIM Inventory record by ID
*
* @param simInventoryId - Salesforce ID of the SIM_Inventory__c record
* @returns SIM Inventory details or null if not found
*/
async getSimInventoryById(simInventoryId: string): Promise<SimInventoryRecord | null> {
const safeId = assertSalesforceId(simInventoryId, "simInventoryId");
this.logger.log("Fetching SIM Inventory record", { simInventoryId: safeId });
// Note: ICCID__c and IMSI__c fields don't exist on SIM_Inventory__c in this org
// Only query fields that actually exist
const soql = `
SELECT Id, Phone_Number__c, PT_Number__c, OEM_ID__c, Status__c
FROM SIM_Inventory__c
WHERE Id = '${safeId}'
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "sim-inventory:getById",
})) as SalesforceSIMInventoryResponse;
const record = result.records?.[0];
if (!record) {
this.logger.warn("SIM Inventory record not found", { simInventoryId: safeId });
return null;
}
this.logger.log("SIM Inventory record retrieved", {
simInventoryId: safeId,
status: record.Status__c,
hasPhoneNumber: !!record.Phone_Number__c,
hasPtNumber: !!record.PT_Number__c,
});
return this.mapToSimInventoryRecord(record);
} catch (error: unknown) {
this.logger.error("Failed to fetch SIM Inventory record", {
simInventoryId: safeId,
error: extractErrorMessage(error),
});
throw error;
}
}
/**
* Get and validate SIM Inventory for activation
*
* Fetches the SIM Inventory record and validates:
* - Record exists
* - Status is "Available"
* - PT_Number__c (productNumber) is set
* - Phone_Number__c (MSISDN) is set
*
* @throws SimActivationException if validation fails
*/
async getAndValidateForActivation(simInventoryId: string): Promise<SimInventoryRecord> {
const record = await this.getSimInventoryById(simInventoryId);
if (!record) {
throw new SimActivationException("SIM Inventory record not found", {
simInventoryId,
});
}
// Validate status is "Available"
if (record.status !== SIM_INVENTORY_STATUS.AVAILABLE) {
throw new SimActivationException(
`SIM is not available for activation. Current status: ${record.status}`,
{
simInventoryId,
currentStatus: record.status,
expectedStatus: SIM_INVENTORY_STATUS.AVAILABLE,
}
);
}
// Validate PT Number is set
if (!record.ptNumber) {
throw new SimActivationException("SIM Inventory record missing PT Number", {
simInventoryId,
});
}
// Validate Phone Number is set
if (!record.phoneNumber) {
throw new SimActivationException("SIM Inventory record missing Phone Number (MSISDN)", {
simInventoryId,
});
}
this.logger.log("SIM Inventory validated for activation", {
simInventoryId,
phoneNumber: record.phoneNumber,
ptNumber: record.ptNumber,
});
return record;
}
/**
* Update SIM Inventory status to "Assigned" after successful activation
*/
async markAsAssigned(simInventoryId: string): Promise<void> {
const safeId = assertSalesforceId(simInventoryId, "simInventoryId");
this.logger.log("Marking SIM Inventory as Assigned", { simInventoryId: safeId });
try {
await this.sf.sobject("SIM_Inventory__c").update?.({
Id: safeId,
Status__c: SIM_INVENTORY_STATUS.ASSIGNED,
});
this.logger.log("SIM Inventory marked as Assigned", { simInventoryId: safeId });
} catch (error: unknown) {
this.logger.error("Failed to update SIM Inventory status", {
simInventoryId: safeId,
error: extractErrorMessage(error),
});
throw error;
}
}
/**
* Update SIM Inventory status
*
* @param simInventoryId - Salesforce ID
* @param status - New status value
*/
async updateStatus(simInventoryId: string, status: SimInventoryStatus): Promise<void> {
const safeId = assertSalesforceId(simInventoryId, "simInventoryId");
this.logger.log("Updating SIM Inventory status", {
simInventoryId: safeId,
newStatus: status,
});
try {
await this.sf.sobject("SIM_Inventory__c").update?.({
Id: safeId,
Status__c: status,
});
this.logger.log("SIM Inventory status updated", {
simInventoryId: safeId,
newStatus: status,
});
} catch (error: unknown) {
this.logger.error("Failed to update SIM Inventory status", {
simInventoryId: safeId,
newStatus: status,
error: extractErrorMessage(error),
});
throw error;
}
}
/**
* Map raw Salesforce record to domain type
*/
private mapToSimInventoryRecord(
raw: SalesforceSIMInventoryResponse["records"][0]
): SimInventoryRecord {
return {
id: raw.Id,
phoneNumber: raw.Phone_Number__c ?? "",
ptNumber: raw.PT_Number__c ?? "",
oemId: raw.OEM_ID__c ?? undefined,
status: (raw.Status__c as SimInventoryStatus) ?? SIM_INVENTORY_STATUS.AVAILABLE,
};
}
}

View File

@ -1,4 +1,5 @@
import { Injectable, HttpStatus } from "@nestjs/common";
import { Injectable, HttpStatus, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common";
import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
@ -11,6 +12,7 @@ import { matchCommonError } from "@bff/core/errors/index.js";
*/
@Injectable()
export class WhmcsErrorHandlerService {
constructor(@Inject(Logger) private readonly logger: Logger) {}
/**
* Handle WHMCS API error response
*/
@ -27,16 +29,20 @@ export class WhmcsErrorHandlerService {
}
/**
* Handle general request errors (network, timeout, etc.)
* Handle general request errors (network, timeout, HTTP status errors, etc.)
* @param error - The error to handle
* @param _context - Context string for error messages (kept for signature consistency)
*/
handleRequestError(error: unknown, _context?: string): never {
const message = extractErrorMessage(error);
if (this.isTimeoutError(error)) {
this.logger.warn("WHMCS request timeout", { error: message });
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT);
}
if (this.isNetworkError(error)) {
this.logger.warn("WHMCS network error", { error: message });
throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY);
}
@ -48,12 +54,33 @@ export class WhmcsErrorHandlerService {
);
}
// Check for HTTP status errors (e.g., "HTTP 500: Internal Server Error")
const httpStatusError = this.parseHttpStatusError(message);
if (httpStatusError) {
this.logger.error("WHMCS HTTP status error", {
upstreamStatus: httpStatusError.status,
upstreamStatusText: httpStatusError.statusText,
originalError: message,
});
// Map upstream HTTP status to appropriate domain error
const mapped = this.mapHttpStatusToDomainError(httpStatusError.status);
throw new DomainHttpException(mapped.code, mapped.domainStatus, mapped.message);
}
// Re-throw if already a DomainHttpException
if (error instanceof DomainHttpException) {
throw error;
}
// Wrap unknown errors
// Log unhandled errors for debugging
this.logger.error("WHMCS unhandled request error", {
error: message,
errorType: error instanceof Error ? error.constructor.name : typeof error,
stack: error instanceof Error ? error.stack : undefined,
});
// Wrap unknown errors with context
throw new DomainHttpException(
ErrorCode.EXTERNAL_SERVICE_ERROR,
HttpStatus.BAD_GATEWAY,
@ -61,6 +88,89 @@ export class WhmcsErrorHandlerService {
);
}
/**
* Parse HTTP status error from error message (e.g., "HTTP 500: Internal Server Error")
*/
private parseHttpStatusError(
message: string
): { status: number; statusText: string } | null {
const match = message.match(/^HTTP (\d{3}):\s*(.*)$/i);
if (match) {
return {
status: parseInt(match[1], 10),
statusText: match[2] || "Unknown",
};
}
return null;
}
/**
* Map upstream HTTP status codes to domain errors
*/
private mapHttpStatusToDomainError(upstreamStatus: number): {
code: ErrorCodeType;
domainStatus: HttpStatus;
message: string;
} {
// 4xx errors from WHMCS typically indicate config/permission issues
if (upstreamStatus === 401 || upstreamStatus === 403) {
return {
code: ErrorCode.EXTERNAL_SERVICE_ERROR,
domainStatus: HttpStatus.SERVICE_UNAVAILABLE,
message: `WHMCS authentication/authorization failed (HTTP ${upstreamStatus}). Check API credentials and permissions.`,
};
}
if (upstreamStatus === 404) {
return {
code: ErrorCode.EXTERNAL_SERVICE_ERROR,
domainStatus: HttpStatus.BAD_GATEWAY,
message: `WHMCS endpoint not found (HTTP 404). Check WHMCS configuration.`,
};
}
if (upstreamStatus >= 400 && upstreamStatus < 500) {
return {
code: ErrorCode.EXTERNAL_SERVICE_ERROR,
domainStatus: HttpStatus.BAD_GATEWAY,
message: `WHMCS client error (HTTP ${upstreamStatus}). Request may be malformed.`,
};
}
// 5xx errors indicate WHMCS server issues
if (upstreamStatus === 500) {
return {
code: ErrorCode.EXTERNAL_SERVICE_ERROR,
domainStatus: HttpStatus.BAD_GATEWAY,
message: `WHMCS internal server error (HTTP 500). The WHMCS server encountered an error.`,
};
}
if (upstreamStatus === 502 || upstreamStatus === 503 || upstreamStatus === 504) {
return {
code: ErrorCode.SERVICE_UNAVAILABLE,
domainStatus: HttpStatus.SERVICE_UNAVAILABLE,
message: `WHMCS service unavailable (HTTP ${upstreamStatus}). The server may be down or overloaded.`,
};
}
// Default for other 5xx errors
if (upstreamStatus >= 500) {
return {
code: ErrorCode.EXTERNAL_SERVICE_ERROR,
domainStatus: HttpStatus.BAD_GATEWAY,
message: `WHMCS server error (HTTP ${upstreamStatus}).`,
};
}
// Fallback for unexpected status codes
return {
code: ErrorCode.EXTERNAL_SERVICE_ERROR,
domainStatus: HttpStatus.BAD_GATEWAY,
message: `Unexpected WHMCS response (HTTP ${upstreamStatus}).`,
};
}
private mapProviderErrorToDomain(
action: string,
message: string,

View File

@ -288,12 +288,12 @@ export class WhmcsHttpClientService {
// For successful responses, WHMCS API returns data directly at the root level
// The response structure is: { "result": "success", ...actualData }
// We return the parsed response directly as T since it contains the actual data
const { result, message, ...rest } = parsedResponse;
// We include 'result' in the data so downstream services can verify success if needed
const { message, ...dataWithResult } = parsedResponse;
return {
result,
result: parsedResponse.result,
message: typeof message === "string" ? message : undefined,
data: rest as T,
data: dataWithResult as T,
} satisfies WhmcsResponse<T>;
}

View File

@ -323,7 +323,22 @@ export class WhmcsInvoiceService {
await this.connectionService.createInvoice(whmcsParams);
if (response.result !== "success") {
throw new WhmcsOperationException(`WHMCS invoice creation failed: ${response.message}`, {
// Log full response for debugging (WHMCS may return error info in different fields)
this.logger.error("WHMCS CreateInvoice returned non-success result", {
clientId: params.clientId,
result: response.result,
message: response.message,
status: response.status,
invoiceid: response.invoiceid,
fullResponse: JSON.stringify(response),
});
const errorMessage =
response.message ||
(response as Record<string, unknown>).error ||
`Unknown error (result: ${response.result})`;
throw new WhmcsOperationException(`WHMCS invoice creation failed: ${errorMessage}`, {
clientId: params.clientId,
});
}
@ -478,6 +493,71 @@ export class WhmcsInvoiceService {
}
}
/**
* Refund a payment by adding a credit transaction and marking invoice as refunded
* Used for testing to reverse charges immediately after capture
*/
async refundPayment(params: {
invoiceId: number;
transactionId: string;
amount: number;
reason?: string;
}): Promise<{ success: boolean; error?: string }> {
try {
this.logger.log(`Processing refund for invoice ${params.invoiceId}`, {
invoiceId: params.invoiceId,
transactionId: params.transactionId,
amount: params.amount,
reason: params.reason || "Test refund",
});
// Add a credit transaction to reverse the charge
const addTransactionResponse = await this.connectionService.makeRequest<{
result: string;
transactionid?: number;
message?: string;
}>("AddTransaction", {
invoiceid: params.invoiceId,
transid: `REFUND-${params.transactionId}`,
gateway: "stripe",
date: new Date().toISOString().split("T")[0],
amountin: 0,
amountout: params.amount, // Outgoing amount = refund
description: params.reason || "Test refund - payment reversed",
});
if (addTransactionResponse.result !== "success") {
this.logger.warn(`Failed to add refund transaction for invoice ${params.invoiceId}`, {
response: addTransactionResponse,
});
}
// Mark invoice as refunded
await this.updateInvoice({
invoiceId: params.invoiceId,
status: "Refunded",
notes: `Refunded: ${params.reason || "Test mode - automatic refund"}`,
});
this.logger.log(`Successfully refunded invoice ${params.invoiceId}`, {
invoiceId: params.invoiceId,
amount: params.amount,
});
return { success: true };
} catch (error) {
this.logger.error(`Failed to refund invoice ${params.invoiceId}`, {
error: extractErrorMessage(error),
params,
});
return {
success: false,
error: extractErrorMessage(error),
};
}
}
/**
* Convert technical payment errors to user-friendly messages
*/

View File

@ -119,8 +119,13 @@ export class WhmcsOrderService {
* WHMCS API Response Structure:
* Success: { orderid, invoiceid, serviceids, addonids, domainids }
* Error: Thrown by HTTP client before returning
*
* @returns Service IDs created by AcceptOrder (services are created on accept, not on add)
*/
async acceptOrder(orderId: number, sfOrderId?: string): Promise<void> {
async acceptOrder(
orderId: number,
sfOrderId?: string
): Promise<{ serviceIds: number[]; invoiceId?: number }> {
this.logger.log("Accepting WHMCS order", {
orderId,
sfOrderId,
@ -152,11 +157,19 @@ export class WhmcsOrderService {
});
}
const serviceIds = this.parseDelimitedIds(parsedResponse.data.serviceids);
const invoiceId = parsedResponse.data.invoiceid
? parseInt(String(parsedResponse.data.invoiceid), 10)
: undefined;
this.logger.log("WHMCS order accepted successfully", {
orderId,
invoiceId: parsedResponse.data.invoiceid,
invoiceId,
serviceIds,
sfOrderId,
});
return { serviceIds, invoiceId };
} catch (error) {
// Enhanced error logging with full context
this.logger.error("Failed to accept WHMCS order", {

View File

@ -25,15 +25,27 @@ export class ProvisioningProcessor extends WorkerHost {
correlationId: job.data.correlationId,
});
// Guard: Only process if Salesforce Order is currently 'Activating'
const order = await this.salesforceService.getOrder(sfOrderId);
const status = order?.Activation_Status__c ?? "";
const activationStatus = order?.Activation_Status__c ?? "";
const orderStatus = order?.Status ?? "";
const simType = order?.SIM_Type__c ?? "";
const assignedPhysicalSim = order?.Assign_Physical_SIM__c ?? "";
const lastErrorCode = order?.Activation_Error_Code__c ?? "";
if (status !== "Activating") {
this.logger.log("Skipping provisioning job: Order not in Activating state", {
// Guard: Determine if this is a valid provisioning request
// Case 1: Standard flow - Activation_Status__c = "Activating"
// Case 2: Physical SIM flow - Status = "Approved" with SIM_Type__c = "Physical SIM"
const isStandardActivation = activationStatus === "Activating";
const isPhysicalSimApproval =
orderStatus === "Approved" && simType === "Physical SIM" && !!assignedPhysicalSim;
if (!isStandardActivation && !isPhysicalSimApproval) {
this.logger.log("Skipping provisioning job: Order not in activatable state", {
sfOrderId,
currentStatus: status,
activationStatus,
orderStatus,
simType,
hasAssignedPhysicalSim: !!assignedPhysicalSim,
});
return; // Ack + no-op to safely handle duplicate/old events
}
@ -42,12 +54,19 @@ export class ProvisioningProcessor extends WorkerHost {
if (lastErrorCode === "PAYMENT_METHOD_MISSING") {
this.logger.log("Skipping provisioning job: Awaiting payment method addition", {
sfOrderId,
currentStatus: status,
activationStatus,
lastErrorCode,
});
return;
}
this.logger.log("Proceeding with provisioning", {
sfOrderId,
isStandardActivation,
isPhysicalSimApproval,
simType,
});
// Execute the same orchestration used by the webhook path, but without payload validation
await this.orchestrator.executeFulfillment(sfOrderId, {}, idempotencyKey);
this.logger.log("Provisioning job completed", { sfOrderId });

View File

@ -1,14 +1,13 @@
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceFacade } from "@bff/integrations/salesforce/facades/salesforce.facade.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
import type {
OrderFulfillmentValidationResult,
SalesforceOrderRecord,
} from "@customer-portal/domain/orders/providers";
import type { OrderFulfillmentValidationResult } from "@customer-portal/domain/orders/providers";
import { salesforceAccountIdSchema } from "@customer-portal/domain/common";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders/providers";
import { PaymentValidatorService } from "./payment-validator.service.js";
/**
@ -19,7 +18,8 @@ import { PaymentValidatorService } from "./payment-validator.service.js";
export class OrderFulfillmentValidator {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly salesforceService: SalesforceFacade,
private readonly salesforceService: SalesforceService,
private readonly salesforceAccountService: SalesforceAccountService,
private readonly mappingsService: MappingsService,
private readonly paymentValidator: PaymentValidatorService
) {}
@ -64,13 +64,45 @@ export class OrderFulfillmentValidator {
// Validate AccountId using schema instead of manual type checks
const accountId = salesforceAccountIdSchema.parse(sfOrder.AccountId);
const mapping = await this.mappingsService.findBySfAccountId(accountId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException(`No WHMCS client mapping found for account ${accountId}`);
}
const clientId = mapping.whmcsClientId;
let clientId: number;
let userId: string | undefined;
// 4. Validate payment method exists
await this.validatePaymentMethod(clientId, mapping.userId);
if (mapping?.whmcsClientId) {
clientId = mapping.whmcsClientId;
userId = mapping.userId;
} else {
// Fallback: Try to get WHMCS client ID from Salesforce Account's WH_Account__c field
const sfAccount = await this.salesforceAccountService.getAccountDetails(accountId);
const whmcsClientId = this.parseWhmcsClientIdFromField(sfAccount?.WH_Account__c);
if (!whmcsClientId) {
throw new BadRequestException(`No WHMCS client mapping found for account ${accountId}`);
}
this.logger.log(
"Using WHMCS client ID from Salesforce Account field (no database mapping)",
{
accountId,
whmcsClientId,
whAccountField: sfAccount?.WH_Account__c,
}
);
clientId = whmcsClientId;
// Try to find userId by WHMCS client ID for payment validation
const mappingByWhmcs = await this.mappingsService.findByWhmcsClientId(whmcsClientId);
userId = mappingByWhmcs?.userId;
}
// 4. Validate payment method exists (skip if no userId available)
if (userId) {
await this.validatePaymentMethod(clientId, userId);
} else {
this.logger.warn("Skipping payment method validation - no userId available", {
accountId,
clientId,
});
}
this.logger.log("Fulfillment validation completed successfully", {
sfOrderId,
@ -124,4 +156,30 @@ export class OrderFulfillmentValidator {
private async validatePaymentMethod(clientId: number, userId: string): Promise<void> {
return this.paymentValidator.validatePaymentMethodExists(userId, clientId);
}
/**
* Parse WHMCS client ID from the WH_Account__c field value
* Format: "#9883 - Temuulen Ankhbayar" -> 9883
*/
private parseWhmcsClientIdFromField(whAccountField: string | null | undefined): number | null {
if (!whAccountField) {
return null;
}
// Match "#<number>" pattern at the start of the string
const match = whAccountField.match(/^#(\d+)/);
if (!match || !match[1]) {
this.logger.warn("Could not parse WHMCS client ID from WH_Account__c field", {
whAccountField,
});
return null;
}
const clientId = parseInt(match[1], 10);
if (isNaN(clientId) || clientId <= 0) {
return null;
}
return clientId;
}
}

View File

@ -1,84 +1,108 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
import { FreebitAccountRegistrationService } from "@bff/integrations/freebit/services/freebit-account-registration.service.js";
import { FreebitVoiceOptionsService } from "@bff/integrations/freebit/services/freebit-voice-options.service.js";
import { SalesforceSIMInventoryService } from "@bff/integrations/salesforce/services/salesforce-sim-inventory.service.js";
import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders";
import { mapProductToFreebitPlanCode } from "@customer-portal/domain/sim";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import {
SimActivationException,
OrderValidationException,
} from "@bff/core/exceptions/domain-exceptions.js";
/**
* Contact identity data for PA05-05 voice option registration
*/
export interface ContactIdentityData {
firstnameKanji: string;
lastnameKanji: string;
firstnameKana: string;
lastnameKana: string;
gender: "M" | "F";
birthday: string; // YYYYMMDD format
}
export interface SimFulfillmentRequest {
orderDetails: OrderDetails;
configurations: Record<string, unknown>;
/** Salesforce ID of the assigned Physical SIM (from Assign_Physical_SIM__c) */
assignedPhysicalSimId?: string;
/** Voice Mail enabled from Order.SIM_Voice_Mail__c */
voiceMailEnabled?: boolean;
/** Call Waiting enabled from Order.SIM_Call_Waiting__c */
callWaitingEnabled?: boolean;
/** Contact identity data for PA05-05 */
contactIdentity?: ContactIdentityData;
}
type SimType = "eSIM" | "Physical SIM";
type ActivationType = "Immediate" | "Scheduled";
interface MnpConfig {
reserveNumber?: string;
reserveExpireDate?: string;
account?: string;
firstnameKanji?: string;
lastnameKanji?: string;
firstnameZenKana?: string;
lastnameZenKana?: string;
gender?: string;
birthday?: string;
}
interface ParsedSimConfig {
simType: SimType;
eid: string | undefined;
activationType: ActivationType;
scheduledAt: string | undefined;
phoneNumber: string | undefined;
mnp: MnpConfig | undefined;
/**
* Result from SIM fulfillment containing inventory data for WHMCS
*/
export interface SimFulfillmentResult {
/** Whether the SIM was successfully activated */
activated: boolean;
/** SIM type that was activated */
simType: "eSIM" | "Physical SIM";
/** Phone number from SIM inventory (for WHMCS custom fields) */
phoneNumber?: string;
/** PT Number / Serial number from SIM inventory (for WHMCS custom fields) */
serialNumber?: string;
/** Salesforce SIM Inventory ID */
simInventoryId?: string;
}
@Injectable()
export class SimFulfillmentService {
constructor(
private readonly freebit: FreebitFacade,
private readonly freebit: FreebitOrchestratorService,
private readonly freebitAccountReg: FreebitAccountRegistrationService,
private readonly freebitVoiceOptions: FreebitVoiceOptionsService,
private readonly simInventory: SalesforceSIMInventoryService,
@Inject(Logger) private readonly logger: Logger
) {}
async fulfillSimOrder(request: SimFulfillmentRequest): Promise<void> {
const { orderDetails, configurations } = request;
async fulfillSimOrder(request: SimFulfillmentRequest): Promise<SimFulfillmentResult> {
const {
orderDetails,
configurations,
assignedPhysicalSimId,
voiceMailEnabled = false,
callWaitingEnabled = false,
contactIdentity,
} = request;
const simType = this.readEnum(configurations.simType, ["eSIM", "Physical SIM"]);
this.logger.log("Starting SIM fulfillment", {
orderId: orderDetails.id,
orderType: orderDetails.orderType,
simType: simType ?? "(not set)",
hasAssignedPhysicalSim: !!assignedPhysicalSimId,
voiceMailEnabled,
callWaitingEnabled,
hasContactIdentity: !!contactIdentity,
});
const config = this.parseSimConfig(configurations);
const planSku = this.extractPlanSku(orderDetails);
// Validate SIM type is explicitly set - don't default to eSIM
if (!simType) {
throw new SimActivationException(
"SIM Type must be explicitly set to 'eSIM' or 'Physical SIM'",
{
orderId: orderDetails.id,
configuredSimType: configurations.simType,
}
);
}
this.validateSimConfig(orderDetails.id, config);
const eid = this.readString(configurations.eid);
const activationType =
this.readEnum(configurations.activationType, ["Immediate", "Scheduled"]) ?? "Immediate";
const scheduledAt = this.readString(configurations.scheduledAt);
const phoneNumber = this.readString(configurations.mnpPhone);
const mnp = this.extractMnpConfig(configurations);
await this.executeSimActivation(orderDetails.id, config, planSku);
this.logger.log("SIM fulfillment completed successfully", {
orderId: orderDetails.id,
account: config.phoneNumber,
planSku,
});
}
private parseSimConfig(configurations: Record<string, unknown>): ParsedSimConfig {
return {
simType: this.readEnum(configurations["simType"], ["eSIM", "Physical SIM"]) ?? "eSIM",
eid: this.readString(configurations["eid"]),
activationType:
this.readEnum(configurations["activationType"], ["Immediate", "Scheduled"]) ?? "Immediate",
scheduledAt: this.readString(configurations["scheduledAt"]),
phoneNumber: this.readString(configurations["mnpPhone"]),
mnp: this.extractMnpConfig(configurations),
};
}
private extractPlanSku(orderDetails: OrderDetails): string {
const simPlanItem = orderDetails.items.find(
(item: OrderItemDetails) =>
item.product?.itemClass === "Plan" || item.product?.sku?.toLowerCase().includes("sim")
@ -91,6 +115,7 @@ export class SimFulfillmentService {
}
const planSku = simPlanItem.product?.sku;
const planName = simPlanItem.product?.name;
if (!planSku) {
throw new OrderValidationException("SIM plan SKU not found", {
orderId: orderDetails.id,
@ -98,77 +123,128 @@ export class SimFulfillmentService {
});
}
return planSku;
}
private validateSimConfig(orderId: string, config: ParsedSimConfig): void {
if (config.simType === "eSIM" && (!config.eid || config.eid.length < 15)) {
throw new SimActivationException("EID is required for eSIM and must be valid", {
orderId,
simType: config.simType,
eidLength: config.eid?.length,
});
}
if (!config.phoneNumber) {
throw new SimActivationException("Phone number is required for SIM activation", {
orderId,
});
}
}
private async executeSimActivation(
orderId: string,
config: ParsedSimConfig,
planSku: string
): Promise<void> {
const { simType, eid, activationType, scheduledAt, phoneNumber, mnp } = config;
if (simType === "eSIM") {
if (!eid) {
throw new SimActivationException("EID is required for eSIM activation", { orderId });
// eSIM activation flow
if (!eid || eid.length < 15) {
throw new SimActivationException("EID is required for eSIM and must be valid", {
orderId: orderDetails.id,
simType,
eidLength: eid?.length,
});
}
if (!phoneNumber) {
throw new SimActivationException("Phone number is required for eSIM activation", {
orderId: orderDetails.id,
});
}
await this.activateEsim({
account: phoneNumber!,
account: phoneNumber,
eid,
planSku,
activationType,
scheduledAt,
mnp,
});
} else {
await this.activatePhysicalSim({
account: phoneNumber!,
this.logger.log("eSIM fulfillment completed successfully", {
orderId: orderDetails.id,
account: phoneNumber,
planSku,
activationType,
scheduledAt,
});
return {
activated: true,
simType: "eSIM",
phoneNumber,
};
} else {
// Physical SIM activation flow (PA02-01 + PA05-05)
if (!assignedPhysicalSimId) {
throw new SimActivationException(
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
{ orderId: orderDetails.id }
);
}
const simData = await this.activatePhysicalSim({
orderId: orderDetails.id,
simInventoryId: assignedPhysicalSimId,
planSku,
planName,
voiceMailEnabled,
callWaitingEnabled,
contactIdentity,
});
this.logger.log("Physical SIM fulfillment completed successfully", {
orderId: orderDetails.id,
simInventoryId: assignedPhysicalSimId,
planSku,
voiceMailEnabled,
callWaitingEnabled,
phoneNumber: simData.phoneNumber,
serialNumber: simData.serialNumber,
});
return {
activated: true,
simType: "Physical SIM",
phoneNumber: simData.phoneNumber,
serialNumber: simData.serialNumber,
simInventoryId: assignedPhysicalSimId,
};
}
}
/**
* Activate eSIM via Freebit PA05-41 API
*/
private async activateEsim(params: {
account: string;
eid: string;
planSku: string;
activationType: ActivationType;
scheduledAt: string | undefined;
mnp: MnpConfig | undefined;
activationType: "Immediate" | "Scheduled";
scheduledAt?: string;
mnp?: {
reserveNumber?: string;
reserveExpireDate?: string;
account?: string;
firstnameKanji?: string;
lastnameKanji?: string;
firstnameZenKana?: string;
lastnameZenKana?: string;
gender?: string;
birthday?: string;
};
}): Promise<void> {
const { account, eid, planSku, activationType, scheduledAt, mnp } = params;
try {
const shipDate = activationType === "Scheduled" ? scheduledAt : undefined;
const mnpData = this.buildMnpData(mnp);
const identityData = this.buildIdentityData(mnp);
await this.freebit.activateEsimAccountNew({
account,
eid,
planCode: planSku,
contractLine: "5G",
...(shipDate !== undefined && { shipDate }),
...(mnpData !== undefined && { mnp: mnpData }),
...(identityData !== undefined && { identity: identityData }),
shipDate: activationType === "Scheduled" ? scheduledAt : undefined,
mnp:
mnp && mnp.reserveNumber && mnp.reserveExpireDate
? {
reserveNumber: mnp.reserveNumber,
reserveExpireDate: mnp.reserveExpireDate,
}
: undefined,
identity: mnp
? {
firstnameKanji: mnp.firstnameKanji,
lastnameKanji: mnp.lastnameKanji,
firstnameZenKana: mnp.firstnameZenKana,
lastnameZenKana: mnp.lastnameZenKana,
gender: mnp.gender,
birthday: mnp.birthday,
}
: undefined,
});
this.logger.log("eSIM activated successfully", {
@ -186,59 +262,150 @@ export class SimFulfillmentService {
}
}
/**
* Activate Physical SIM via Freebit PA02-01 + PA05-05 APIs
*
* Flow for Physical SIMs:
* 1. Fetch SIM Inventory details from Salesforce
* 2. Validate SIM status is "Available"
* 3. Map product SKU to Freebit plan code
* 4. Call Freebit PA02-01 (Account Registration) with createType="new"
* 5. Call Freebit PA05-05 (Voice Options) to configure voice features
* 6. Update SIM Inventory status to "Used"
*/
private async activatePhysicalSim(params: {
account: string;
orderId: string;
simInventoryId: string;
planSku: string;
activationType: ActivationType;
scheduledAt: string | undefined;
}): Promise<void> {
const { account, planSku, activationType, scheduledAt } = params;
try {
const topUpOptions: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {};
if (activationType === "Scheduled" && scheduledAt !== undefined) {
topUpOptions.scheduledAt = scheduledAt;
}
await this.freebit.topUpSim(account, 0, topUpOptions);
planName?: string;
voiceMailEnabled: boolean;
callWaitingEnabled: boolean;
contactIdentity?: ContactIdentityData;
}): Promise<{ phoneNumber: string; serialNumber: string }> {
const {
orderId,
simInventoryId,
planSku,
planName,
voiceMailEnabled,
callWaitingEnabled,
contactIdentity,
} = params;
this.logger.log("Physical SIM activation scheduled", {
account,
planSku,
this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", {
orderId,
simInventoryId,
planSku,
voiceMailEnabled,
callWaitingEnabled,
hasContactIdentity: !!contactIdentity,
});
// Step 1 & 2: Fetch and validate SIM Inventory
const simRecord = await this.simInventory.getAndValidateForActivation(simInventoryId);
// Step 3: Map product to Freebit plan code
const planCode = mapProductToFreebitPlanCode(planSku, planName);
if (!planCode) {
throw new SimActivationException(
`Unable to map product to Freebit plan code. SKU: ${planSku}, Name: ${planName}`,
{ orderId, simInventoryId, planSku, planName }
);
}
// Use phone number from SIM inventory
const accountPhoneNumber = simRecord.phoneNumber;
this.logger.log("Physical SIM inventory validated", {
orderId,
simInventoryId,
accountPhoneNumber,
ptNumber: simRecord.ptNumber,
planCode,
});
try {
// Step 4: Call Freebit PA02-01 (Account Registration)
this.logger.log("Calling PA02-01 Account Registration", {
orderId,
account: accountPhoneNumber,
planCode,
});
await this.freebitAccountReg.registerAccount({
account: accountPhoneNumber,
planCode,
createType: "new",
});
this.logger.log("PA02-01 Account Registration successful", {
orderId,
account: accountPhoneNumber,
});
// Step 5: Call Freebit PA05-05 (Voice Options Registration)
// Only call if we have contact identity data
if (contactIdentity) {
this.logger.log("Calling PA05-05 Voice Options Registration", {
orderId,
account: accountPhoneNumber,
voiceMailEnabled,
callWaitingEnabled,
});
await this.freebitVoiceOptions.registerVoiceOptions({
account: accountPhoneNumber,
voiceMailEnabled,
callWaitingEnabled,
identificationData: {
lastnameKanji: contactIdentity.lastnameKanji,
firstnameKanji: contactIdentity.firstnameKanji,
lastnameKana: contactIdentity.lastnameKana,
firstnameKana: contactIdentity.firstnameKana,
gender: contactIdentity.gender,
birthday: contactIdentity.birthday,
},
});
this.logger.log("PA05-05 Voice Options Registration successful", {
orderId,
account: accountPhoneNumber,
});
} else {
this.logger.warn("Skipping PA05-05: No contact identity data provided", {
orderId,
account: accountPhoneNumber,
});
}
// Step 6: Update SIM Inventory status to "Assigned"
await this.simInventory.markAsAssigned(simInventoryId);
this.logger.log("Physical SIM activated successfully", {
orderId,
simInventoryId,
accountPhoneNumber,
planCode,
voiceMailEnabled,
callWaitingEnabled,
});
// Return SIM data for WHMCS custom fields
return {
phoneNumber: simRecord.phoneNumber,
serialNumber: simRecord.ptNumber,
};
} catch (error: unknown) {
this.logger.error("Physical SIM activation failed", {
account,
planSku,
orderId,
simInventoryId,
phoneNumber: simRecord.phoneNumber,
error: extractErrorMessage(error),
});
throw error;
}
}
private buildMnpData(
mnp: MnpConfig | undefined
): { reserveNumber: string; reserveExpireDate: string } | undefined {
if (!mnp?.reserveNumber || !mnp?.reserveExpireDate) {
return undefined;
}
return { reserveNumber: mnp.reserveNumber, reserveExpireDate: mnp.reserveExpireDate };
}
private buildIdentityData(mnp: MnpConfig | undefined): Record<string, string> | undefined {
if (!mnp) {
return undefined;
}
const identity: Record<string, string> = {};
if (mnp.firstnameKanji !== undefined) identity["firstnameKanji"] = mnp.firstnameKanji;
if (mnp.lastnameKanji !== undefined) identity["lastnameKanji"] = mnp.lastnameKanji;
if (mnp.firstnameZenKana !== undefined) identity["firstnameZenKana"] = mnp.firstnameZenKana;
if (mnp.lastnameZenKana !== undefined) identity["lastnameZenKana"] = mnp.lastnameZenKana;
if (mnp.gender !== undefined) identity["gender"] = mnp.gender;
if (mnp.birthday !== undefined) identity["birthday"] = mnp.birthday;
return Object.keys(identity).length > 0 ? identity : undefined;
}
private readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
@ -247,46 +414,54 @@ export class SimFulfillmentService {
return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined;
}
private extractMnpConfig(config: Record<string, unknown>): MnpConfig | undefined {
const nested = config["mnp"];
private extractMnpConfig(config: Record<string, unknown>) {
const nested = config.mnp;
const source =
nested && typeof nested === "object" ? (nested as Record<string, unknown>) : config;
const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]);
const isMnpFlag = this.readString(source.isMnp ?? config.isMnp);
if (isMnpFlag && isMnpFlag !== "true") {
return undefined;
}
const mnpConfig = this.buildMnpConfigFromSource(source);
return this.hasMnpFields(mnpConfig) ? mnpConfig : undefined;
}
const reserveNumber = this.readString(source.mnpNumber ?? source.reserveNumber);
const reserveExpireDate = this.readString(source.mnpExpiry ?? source.reserveExpireDate);
const account = this.readString(source.mvnoAccountNumber ?? source.account);
const firstnameKanji = this.readString(source.portingFirstName ?? source.firstnameKanji);
const lastnameKanji = this.readString(source.portingLastName ?? source.lastnameKanji);
const firstnameZenKana = this.readString(
source.portingFirstNameKatakana ?? source.firstnameZenKana
);
const lastnameZenKana = this.readString(
source.portingLastNameKatakana ?? source.lastnameZenKana
);
const gender = this.readString(source.portingGender ?? source.gender);
const birthday = this.readString(source.portingDateOfBirth ?? source.birthday);
private buildMnpConfigFromSource(source: Record<string, unknown>): MnpConfig {
return this.buildConfigObject([
["reserveNumber", source["mnpNumber"] ?? source["reserveNumber"]],
["reserveExpireDate", source["mnpExpiry"] ?? source["reserveExpireDate"]],
["account", source["mvnoAccountNumber"] ?? source["account"]],
["firstnameKanji", source["portingFirstName"] ?? source["firstnameKanji"]],
["lastnameKanji", source["portingLastName"] ?? source["lastnameKanji"]],
["firstnameZenKana", source["portingFirstNameKatakana"] ?? source["firstnameZenKana"]],
["lastnameZenKana", source["portingLastNameKatakana"] ?? source["lastnameZenKana"]],
["gender", source["portingGender"] ?? source["gender"]],
["birthday", source["portingDateOfBirth"] ?? source["birthday"]],
]);
}
private buildConfigObject(entries: [keyof MnpConfig, unknown][]): MnpConfig {
const config: MnpConfig = {};
for (const [key, value] of entries) {
const strValue = this.readString(value);
if (strValue !== undefined) {
config[key] = strValue;
}
if (
!reserveNumber &&
!reserveExpireDate &&
!account &&
!firstnameKanji &&
!lastnameKanji &&
!firstnameZenKana &&
!lastnameZenKana &&
!gender &&
!birthday
) {
return undefined;
}
return config;
}
private hasMnpFields(config: MnpConfig): boolean {
return Object.values(config).some(value => value !== undefined);
return {
reserveNumber,
reserveExpireDate,
account,
firstnameKanji,
lastnameKanji,
firstnameZenKana,
lastnameZenKana,
gender,
birthday,
};
}
}

View File

@ -33,7 +33,7 @@ export class SimServicesService extends BaseServicesService {
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildServicesQuery("SIM", [
const soql = this.buildServicesQuery("Sim", [
"SIM_Data_Size__c",
"SIM_Plan_Type__c",
"SIM_Has_Family_Discount__c",
@ -68,7 +68,7 @@ export class SimServicesService extends BaseServicesService {
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildProductQuery("SIM", "Activation", ["Catalog_Order__c"]);
const soql = this.buildProductQuery("Sim", "Activation", ["Catalog_Order__c"]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"SIM Activation Fees"
@ -120,7 +120,7 @@ export class SimServicesService extends BaseServicesService {
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildProductQuery("SIM", "Add-on", [
const soql = this.buildProductQuery("Sim", "Add-on", [
"Billing_Cycle__c",
"Catalog_Order__c",
"Bundled_Addon__c",

View File

@ -1,4 +1,5 @@
import { BadRequestException, Inject, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -6,6 +7,7 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
export interface SimChargeInvoiceResult {
invoice: { id: number; number: string; total: number; status: string };
transactionId?: string;
refunded?: boolean;
}
interface OneTimeChargeParams {
@ -23,10 +25,19 @@ interface OneTimeChargeParams {
@Injectable()
export class SimBillingService {
private readonly testMode: boolean;
constructor(
private readonly whmcsInvoiceService: WhmcsInvoiceService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {}
) {
// Enable test mode via environment variable
this.testMode = this.configService.get<string>("SIM_BILLING_TEST_MODE") === "true";
if (this.testMode) {
this.logger.warn("SIM Billing is in TEST MODE - payments will be automatically refunded");
}
}
async createOneTimeCharge(params: OneTimeChargeParams): Promise<SimChargeInvoiceResult> {
const {
@ -47,15 +58,15 @@ export class SimBillingService {
description,
amount: amountJpy,
currency,
...(dueDate === undefined ? {} : { dueDate }),
...(notes === undefined ? {} : { notes }),
dueDate,
notes,
});
const paymentResult = await this.whmcsInvoiceService.capturePayment({
invoiceId: invoice.id,
amount: amountJpy,
currency,
...(userId === undefined ? {} : { userId }),
userId,
});
if (!paymentResult.success) {
@ -80,14 +91,44 @@ export class SimBillingService {
description,
amountJpy,
transactionId: paymentResult.transactionId,
testMode: this.testMode,
...metadata,
});
// In test mode, automatically refund the payment
let refunded = false;
if (this.testMode && paymentResult.transactionId) {
this.logger.log("TEST MODE: Automatically refunding payment", {
invoiceId: invoice.id,
transactionId: paymentResult.transactionId,
amount: amountJpy,
});
const refundResult = await this.whmcsInvoiceService.refundPayment({
invoiceId: invoice.id,
transactionId: paymentResult.transactionId,
amount: amountJpy,
reason: "TEST MODE - Automatic refund for SIM billing test",
});
if (refundResult.success) {
refunded = true;
this.logger.log("TEST MODE: Payment refunded successfully", {
invoiceId: invoice.id,
amount: amountJpy,
});
} else {
this.logger.warn("TEST MODE: Failed to refund payment", {
invoiceId: invoice.id,
error: refundResult.error,
});
}
}
return {
invoice,
...(paymentResult.transactionId === undefined
? {}
: { transactionId: paymentResult.transactionId }),
transactionId: paymentResult.transactionId,
refunded,
};
}

View File

@ -122,10 +122,32 @@ export class SimOrchestrator {
*/
async getSimInfo(userId: string, subscriptionId: number): Promise<SimInfo> {
try {
const [details, usage] = await Promise.all([
this.getSimDetails(userId, subscriptionId),
this.getSimUsage(userId, subscriptionId),
]);
// Fetch details first (required)
const details = await this.getSimDetails(userId, subscriptionId);
// Fetch usage separately - gracefully handle errors (e.g., error 210 = no traffic data)
let usage: SimUsage;
try {
usage = await this.getSimUsage(userId, subscriptionId);
} catch (usageError) {
// Log but don't fail - return default usage values
this.logger.warn(
`Failed to get SIM usage for subscription ${subscriptionId}, using defaults`,
{
error: extractErrorMessage(usageError),
userId,
subscriptionId,
note: "This is normal for new SIMs or accounts without traffic data (error 210)",
}
);
usage = {
account: details.account || "",
todayUsageMb: 0,
todayUsageKb: 0,
recentDaysUsage: [],
isBlacklisted: false,
};
}
// If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G)
// by subtracting measured usage (today + recentDays) from the plan cap.

View File

@ -1,7 +1,6 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js";
import { SubscriptionsService } from "../../subscriptions.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import type { SimValidationResult } from "../interfaces/sim-base.interface.js";
import {
@ -13,8 +12,7 @@ import {
@Injectable()
export class SimValidationService {
constructor(
private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator,
private readonly configService: ConfigService,
private readonly subscriptionsService: SubscriptionsService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -29,7 +27,7 @@ export class SimValidationService {
): Promise<SimValidationResult> {
try {
// Get subscription details to verify it's a SIM service
const subscription = await this.subscriptionsOrchestrator.getSubscriptionById(
const subscription = await this.subscriptionsService.getSubscriptionById(
userId,
subscriptionId
);
@ -42,46 +40,36 @@ export class SimValidationService {
// Extract SIM account identifier (using domain function)
let account = extractSimAccountFromSubscription(subscription);
// If no account found, check for test fallback from env or throw error
// If no account found, log detailed info and throw error
if (!account) {
const testSimAccount = this.configService.get<string>("TEST_SIM_ACCOUNT");
this.logger.error(
`No SIM account identifier found for subscription ${subscriptionId}`,
{
userId,
subscriptionId,
productName: subscription.productName,
domain: subscription.domain,
customFieldKeys: subscription.customFields
? Object.keys(subscription.customFields)
: [],
customFieldValues: subscription.customFields,
orderNumber: subscription.orderNumber,
note: "Set the phone number in 'domain' field or a custom field named 'phone', 'msisdn', 'Phone Number', etc.",
}
);
if (testSimAccount) {
account = testSimAccount;
this.logger.warn(
`No SIM account identifier found for subscription ${subscriptionId}, using TEST_SIM_ACCOUNT fallback`,
{
userId,
subscriptionId,
productName: subscription.productName,
domain: subscription.domain,
customFields: subscription.customFields ? Object.keys(subscription.customFields) : [],
}
);
} else {
throw new BadRequestException(
`No SIM account identifier found for subscription ${subscriptionId}. ` +
"Please ensure the subscription has a valid SIM account number in custom fields."
);
}
throw new BadRequestException(
`No SIM phone number found for this subscription. Please ensure the phone number is set in WHMCS (domain field or custom field named 'Phone Number', 'MSISDN', etc.)`
);
}
// Clean up the account format (using domain function)
account = cleanSimAccount(account);
// Skip phone number format validation for testing
// In production, you might want to add validation back:
// const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0
// if (!/^0\d{9,10}$/.test(cleanAccount)) {
// throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`);
// }
// account = cleanAccount;
this.logger.log(`Using SIM account for testing: ${account}`, {
this.logger.log(`Using SIM account: ${account}`, {
userId,
subscriptionId,
account,
note: "Phone number format validation skipped for testing",
});
return { account };
@ -105,45 +93,35 @@ export class SimValidationService {
subscriptionId: number
): Promise<Record<string, unknown>> {
try {
const subscription = await this.subscriptionsOrchestrator.getSubscriptionById(
const subscription = await this.subscriptionsService.getSubscriptionById(
userId,
subscriptionId
);
// Check for specific SIM data (from config or use defaults for testing)
const expectedSimNumber = this.configService.get<string>("TEST_SIM_ACCOUNT", "");
const expectedEid = this.configService.get<string>("TEST_SIM_EID", "");
const foundSimNumber = Object.entries(subscription.customFields || {}).find(
([, value]) =>
value !== undefined &&
value !== null &&
this.formatCustomFieldValue(value).includes(expectedSimNumber)
);
const eidField = Object.entries(subscription.customFields || {}).find(([, value]) => {
if (value === undefined || value === null) return false;
return this.formatCustomFieldValue(value).includes(expectedEid);
});
// Try to extract account using the standard function
const extractedAccount = extractSimAccountFromSubscription(subscription);
return {
subscriptionId,
productName: subscription.productName,
domain: subscription.domain,
orderNumber: subscription.orderNumber,
customFields: subscription.customFields,
isSimService: isSimSubscription(subscription),
groupName: subscription.groupName,
status: subscription.status,
// Specific SIM data checks
expectedSimNumber,
expectedEid,
foundSimNumber: foundSimNumber
? { field: foundSimNumber[0], value: foundSimNumber[1] }
: null,
foundEid: eidField ? { field: eidField[0], value: eidField[1] } : null,
allCustomFieldKeys: Object.keys(subscription.customFields || {}),
allCustomFieldValues: subscription.customFields,
// Account extraction result
extractedAccount,
accountSource: extractedAccount
? subscription.domain
? "domain field"
: "custom field or order number"
: "NOT FOUND - check fields below",
// All custom fields for debugging
customFieldKeys: Object.keys(subscription.customFields || {}),
customFields: subscription.customFields,
hint: !extractedAccount
? "Set phone number in 'domain' field OR add custom field named: phone, msisdn, phonenumber, Phone Number, MSISDN, etc."
: undefined,
};
} catch (error) {
const sanitizedError = extractErrorMessage(error);

View File

@ -9,14 +9,16 @@ import {
Header,
UseGuards,
} from "@nestjs/common";
import { SimOrchestrator } from "./services/sim-orchestrator.service.js";
import { SimTopUpPricingService } from "./services/queries/sim-topup-pricing.service.js";
import { SimPlanService } from "./services/mutations/sim-plan.service.js";
import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js";
import { EsimManagementService } from "./services/mutations/esim-management.service.js";
import { SimManagementService } from "../sim-management.service.js";
import { SimTopUpPricingService } from "./services/sim-topup-pricing.service.js";
import { SimPlanService } from "./services/sim-plan.service.js";
import { SimCancellationService } from "./services/sim-cancellation.service.js";
import { EsimManagementService } from "./services/esim-management.service.js";
import { FreebitRateLimiterService } from "@bff/integrations/freebit/services/freebit-rate-limiter.service.js";
import { AdminGuard } from "@bff/core/security/guards/admin.guard.js";
import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import {
subscriptionIdParamSchema,
simActionResponseSchema,
@ -24,10 +26,10 @@ import {
} from "@customer-portal/domain/subscriptions";
import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions";
import {
simTopUpRequestSchema,
simPlanChangeRequestSchema,
simTopupRequestSchema,
simChangePlanRequestSchema,
simCancelRequestSchema,
simFeaturesUpdateRequestSchema,
simFeaturesRequestSchema,
simTopUpHistoryRequestSchema,
simChangePlanFullRequestSchema,
simCancelFullRequestSchema,
@ -46,17 +48,12 @@ import {
type SimCancellationPreview,
} from "@customer-portal/domain/sim";
// Cache-Control header constants
const CACHE_CONTROL_PUBLIC_1H = "public, max-age=3600";
const CACHE_CONTROL_PRIVATE_5M = "private, max-age=300";
const CACHE_CONTROL_PRIVATE_1M = "private, max-age=60";
// DTOs
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
class SimTopUpRequestDto extends createZodDto(simTopUpRequestSchema) {}
class SimPlanChangeRequestDto extends createZodDto(simPlanChangeRequestSchema) {}
class SimTopupRequestDto extends createZodDto(simTopupRequestSchema) {}
class SimChangePlanRequestDto extends createZodDto(simChangePlanRequestSchema) {}
class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {}
class SimFeaturesUpdateRequestDto extends createZodDto(simFeaturesUpdateRequestSchema) {}
class SimFeaturesRequestDto extends createZodDto(simFeaturesRequestSchema) {}
class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {}
class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {}
@ -80,19 +77,19 @@ class SimCancellationPreviewResponseDto extends createZodDto(simCancellationPrev
@Controller("subscriptions")
export class SimController {
// eslint-disable-next-line max-params -- NestJS dependency injection requires all services to be injected via constructor
constructor(
private readonly simOrchestrator: SimOrchestrator,
private readonly simManagementService: SimManagementService,
private readonly simTopUpPricingService: SimTopUpPricingService,
private readonly simPlanService: SimPlanService,
private readonly simCancellationService: SimCancellationService,
private readonly esimManagementService: EsimManagementService
private readonly esimManagementService: EsimManagementService,
private readonly rateLimiter: FreebitRateLimiterService
) {}
// ==================== Static SIM Routes (must be before :id routes) ====================
@Get("sim/top-up/pricing")
@Header("Cache-Control", CACHE_CONTROL_PUBLIC_1H)
@Header("Cache-Control", "public, max-age=3600")
@ZodResponse({ description: "Get SIM top-up pricing", type: SimTopUpPricingResponseDto })
async getSimTopUpPricing() {
const pricing = await this.simTopUpPricingService.getTopUpPricing();
@ -100,7 +97,7 @@ export class SimController {
}
@Get("sim/top-up/pricing/preview")
@Header("Cache-Control", CACHE_CONTROL_PUBLIC_1H)
@Header("Cache-Control", "public, max-age=3600")
@ZodResponse({
description: "Preview SIM top-up pricing",
type: SimTopUpPricingPreviewResponseDto,
@ -113,7 +110,14 @@ export class SimController {
@Get("debug/sim-details/:account")
@UseGuards(AdminGuard)
async debugSimDetails(@Param("account") account: string) {
return await this.simOrchestrator.getSimDetailsDirectly(account);
return await this.simManagementService.getSimDetailsDebug(account);
}
@Post("debug/sim-rate-limit/clear/:account")
@UseGuards(AdminGuard)
async clearRateLimit(@Param("account") account: string) {
await this.rateLimiter.clearRateLimitForAccount(account);
return { message: `Rate limit cleared for account ${account}` };
}
// ==================== Subscription-specific SIM Routes ====================
@ -124,25 +128,25 @@ export class SimController {
@Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto
): Promise<Record<string, unknown>> {
return this.simOrchestrator.debugSimSubscription(req.user.id, params.id);
return this.simManagementService.debugSimSubscription(req.user.id, params.id);
}
@Get(":id/sim")
@ZodResponse({ description: "Get SIM info", type: SimInfoDto })
async getSimInfo(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
return this.simOrchestrator.getSimInfo(req.user.id, params.id);
return this.simManagementService.getSimInfo(req.user.id, params.id);
}
@Get(":id/sim/details")
@ZodResponse({ description: "Get SIM details", type: SimDetailsDto })
async getSimDetails(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
return this.simOrchestrator.getSimDetails(req.user.id, params.id);
return this.simManagementService.getSimDetails(req.user.id, params.id);
}
@Get(":id/sim/usage")
@ZodResponse({ description: "Get SIM usage", type: SimUsageDto })
async getSimUsage(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) {
return this.simOrchestrator.getSimUsage(req.user.id, params.id);
return this.simManagementService.getSimUsage(req.user.id, params.id);
}
@Get(":id/sim/top-up-history")
@ -152,7 +156,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto,
@Query() query: SimTopUpHistoryRequestDto
) {
return this.simOrchestrator.getSimTopUpHistory(req.user.id, params.id, query);
return this.simManagementService.getSimTopUpHistory(req.user.id, params.id, query);
}
@Post(":id/sim/top-up")
@ -160,9 +164,9 @@ export class SimController {
async topUpSim(
@Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto,
@Body() body: SimTopUpRequestDto
@Body() body: SimTopupRequestDto
): Promise<SimActionResponse> {
await this.simOrchestrator.topUpSim(req.user.id, params.id, body);
await this.simManagementService.topUpSim(req.user.id, params.id, body);
return { message: "SIM top-up completed successfully" };
}
@ -171,9 +175,9 @@ export class SimController {
async changeSimPlan(
@Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto,
@Body() body: SimPlanChangeRequestDto
@Body() body: SimChangePlanRequestDto
): Promise<SimPlanChangeResult> {
const result = await this.simOrchestrator.changeSimPlan(req.user.id, params.id, body);
const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body);
return {
message: "SIM plan change completed successfully",
...result,
@ -187,7 +191,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto,
@Body() body: SimCancelRequestDto
): Promise<SimActionResponse> {
await this.simOrchestrator.cancelSim(req.user.id, params.id, body);
await this.simManagementService.cancelSim(req.user.id, params.id, body);
return { message: "SIM cancellation completed successfully" };
}
@ -198,11 +202,7 @@ export class SimController {
@Param() params: SubscriptionIdParamDto,
@Body() body: SimReissueEsimRequestDto
): Promise<SimActionResponse> {
await this.simOrchestrator.reissueEsimProfile(
req.user.id,
params.id,
body.newEid ? { newEid: body.newEid } : {}
);
await this.simManagementService.reissueEsimProfile(req.user.id, params.id, body.newEid);
return { message: "eSIM profile reissue completed successfully" };
}
@ -211,16 +211,16 @@ export class SimController {
async updateSimFeatures(
@Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto,
@Body() body: SimFeaturesUpdateRequestDto
@Body() body: SimFeaturesRequestDto
): Promise<SimActionResponse> {
await this.simOrchestrator.updateSimFeatures(req.user.id, params.id, body);
await this.simManagementService.updateSimFeatures(req.user.id, params.id, body);
return { message: "SIM features updated successfully" };
}
// ==================== Enhanced SIM Management Endpoints ====================
@Get(":id/sim/available-plans")
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_5M)
@Header("Cache-Control", "private, max-age=300")
@ZodResponse({ description: "Get available SIM plans", type: SimAvailablePlansResponseDto })
async getAvailablePlans(
@Request() req: RequestWithUser,
@ -245,7 +245,7 @@ export class SimController {
}
@Get(":id/sim/cancellation-preview")
@Header("Cache-Control", CACHE_CONTROL_PRIVATE_1M)
@Header("Cache-Control", "private, max-age=60")
@ZodResponse({
description: "Get SIM cancellation preview",
type: SimCancellationPreviewResponseDto,

View File

@ -1,40 +0,0 @@
# =============================================================================
# Customer Portal Frontend - Development Environment
# =============================================================================
# Copy this file to .env.local in apps/portal/:
# cp .env.example .env.local
#
# Note: NEXT_PUBLIC_* variables are exposed to the browser.
# In development, Next.js rewrites /api/* to the BFF, so you typically
# don't need to set NEXT_PUBLIC_API_BASE.
# =============================================================================
# -----------------------------------------------------------------------------
# Application Identity
# -----------------------------------------------------------------------------
NEXT_PUBLIC_APP_NAME=Customer Portal (Dev)
NEXT_PUBLIC_APP_VERSION=1.0.0-dev
# -----------------------------------------------------------------------------
# API Configuration
# -----------------------------------------------------------------------------
# In development: Leave empty or unset - Next.js rewrites /api/* to BFF
# In production: Set to /api (nginx proxies to BFF) or full BFF URL
# NEXT_PUBLIC_API_BASE=
# BFF URL for Next.js rewrites (server-side only, not exposed to browser)
BFF_URL=http://localhost:4000
# -----------------------------------------------------------------------------
# Development Tools
# -----------------------------------------------------------------------------
NEXT_PUBLIC_ENABLE_DEVTOOLS=true
# -----------------------------------------------------------------------------
# Salesforce Embedded Service (Optional - for Agentforce widget)
# -----------------------------------------------------------------------------
# NEXT_PUBLIC_SF_EMBEDDED_SERVICE_URL=
# NEXT_PUBLIC_SF_ORG_ID=
# NEXT_PUBLIC_SF_EMBEDDED_SERVICE_DEPLOYMENT_ID=
# NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SITE_URL=
# NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SCRT2_URL=

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

@ -0,0 +1,4 @@
<svg width="180" height="180" viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Acronis Quick Assist">
<rect width="180" height="180" rx="28" fill="#EEF5FF"/>
<path d="M89.6 32 40 148h18.4l10-24.8h43.6l10 24.8H140L90.4 32h-0.8zm0.4 34.4 16.6 41.6H73.4l16.6-41.6z" fill="#1D6CD5"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,4 @@
<svg width="180" height="180" viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="TeamViewer QS">
<rect width="180" height="180" rx="28" fill="#E9F5FF"/>
<path d="M90 34c-29.8 0-54 24.2-54 54s24.2 54 54 54 54-24.2 54-54-24.2-54-54-54zm-0.1 11.2c23.7 0 42.9 19.2 42.9 42.9S113.6 131 89.9 131 47 111.8 47 88.1 66.2 45.2 89.9 45.2zM69 86.4v3.4l14.2 8.5v-5.8h13.7v5.8L111 89.8v-3.4l-14.1-8.5v5.9H83.2v-5.9L69 86.4z" fill="#0D86D7"/>
</svg>

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -4,8 +4,27 @@
* Corporate profile and company information.
*/
import type { Metadata } from "next";
import { AboutUsView } from "@/features/marketing/views/AboutUsView";
export const metadata: Metadata = {
title: "About Us - 20+ Years Serving Expats in Japan | Assist Solutions",
description:
"Since 2002, Assist Solutions has been the trusted IT partner for expats and international businesses in Japan. Bilingual support, no Japanese required.",
keywords: [
"Assist Solutions expats",
"IT company foreigners Japan",
"English IT support Tokyo",
"expat services Japan",
],
openGraph: {
title: "About Assist Solutions - IT for Expats Since 2002",
description:
"20+ years serving Japan's international community. Internet, mobile, VPN, and tech support with full English support.",
type: "website",
},
};
export default function AboutPage() {
return <AboutUsView />;
}

View File

@ -0,0 +1,23 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Blog & Updates - Assist Solutions",
description:
"Tips, guides, and news for expats and businesses in Japan. Learn about internet setup, SIM cards, VPN services, and life in Japan.",
keywords: [
"Japan expat blog",
"internet Japan guide",
"SIM card Japan",
"living in Japan tips",
"Assist Solutions news",
],
openGraph: {
title: "Blog & Updates - Assist Solutions",
description: "Tips, guides, and news for the international community in Japan.",
type: "website",
},
};
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@ -0,0 +1,200 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { cn } from "@/shared/utils";
// Sample blog data
const categories = [
{ id: "all", label: "Latest", count: 5 },
{ id: "guides", label: "Guides", count: 2 },
{ id: "tech", label: "Tech Tips", count: 2 },
{ id: "news", label: "News", count: 1 },
];
const authors = {
default: {
name: "Author Name",
role: "Assist Solutions",
avatar: "/assets/images/avatar-placeholder.png",
},
};
const blogPosts = [
{
id: "1",
slug: "blog-1",
category: "guides",
categoryLabel: "Guides",
title: "Blog 1",
excerpt:
"Sample blog post content. This is a placeholder for future blog content that will be added to the website.",
image: "/assets/images/blog-placeholder-1.jpg",
author: authors.default,
date: "2025-01-10",
featured: true,
},
{
id: "2",
slug: "blog-2",
category: "tech",
categoryLabel: "Tech Tips",
title: "Blog 2",
excerpt:
"Sample blog post content. This is a placeholder for future blog content that will be added to the website.",
image: "/assets/images/blog-placeholder-2.jpg",
author: authors.default,
date: "2025-01-08",
featured: true,
},
{
id: "3",
slug: "blog-3",
category: "guides",
categoryLabel: "Guides",
title: "Blog 3",
excerpt:
"Sample blog post content. This is a placeholder for future blog content that will be added to the website.",
image: "/assets/images/blog-placeholder-3.jpg",
author: authors.default,
date: "2025-01-05",
},
{
id: "4",
slug: "blog-4",
category: "tech",
categoryLabel: "Tech Tips",
title: "Blog 4",
excerpt:
"Sample blog post content. This is a placeholder for future blog content that will be added to the website.",
image: "/assets/images/blog-placeholder-4.jpg",
author: authors.default,
date: "2025-01-03",
},
{
id: "5",
slug: "blog-5",
category: "news",
categoryLabel: "News",
title: "Blog 5",
excerpt:
"Sample blog post content. This is a placeholder for future blog content that will be added to the website.",
image: "/assets/images/blog-placeholder-5.jpg",
author: authors.default,
date: "2024-12-28",
},
];
export default function BlogPage() {
const [activeCategory, setActiveCategory] = useState("all");
const filteredPosts =
activeCategory === "all"
? blogPosts
: blogPosts.filter(post => post.category === activeCategory);
return (
<div className="max-w-7xl mx-auto px-4 py-8 sm:py-12">
<div className="grid grid-cols-1 lg:grid-cols-[240px_1fr] gap-10 lg:gap-14">
{/* Sidebar */}
<aside className="lg:sticky lg:top-24 lg:self-start">
<div className="mb-8">
<h1 className="text-3xl sm:text-4xl font-extrabold text-foreground leading-tight">
Blog
</h1>
<p className="text-base italic text-primary mt-1">&amp; Updates</p>
<p className="text-sm text-muted-foreground mt-4 leading-relaxed">
Latest updates and information.
</p>
</div>
<div className="border-t border-border/60 pt-6">
<h2 className="text-sm font-bold text-foreground mb-4">Categories</h2>
<nav className="space-y-1">
{categories.map(category => (
<button
key={category.id}
type="button"
onClick={() => setActiveCategory(category.id)}
className={cn(
"w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors",
activeCategory === category.id
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
<span>{category.label}</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
activeCategory === category.id
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
)}
>
{category.count}
</span>
</button>
))}
</nav>
</div>
</aside>
{/* Blog Grid */}
<main>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{filteredPosts.map(post => (
<article key={post.id} className="group">
<Link href={`/blog/${post.slug}`} className="block">
{/* Image */}
<div className="relative aspect-[16/10] rounded-2xl overflow-hidden bg-muted mb-4">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-sky-200/30 flex items-center justify-center">
<span className="text-4xl font-bold text-primary/30">
{post.title.charAt(0)}
</span>
</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* Category */}
<span className="text-xs font-semibold text-primary uppercase tracking-wider">
{post.categoryLabel}
</span>
{/* Title */}
<h2 className="text-xl font-bold text-foreground mt-2 mb-3 group-hover:text-primary transition-colors leading-snug">
{post.title}
</h2>
{/* Excerpt */}
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3 mb-4">
{post.excerpt}
</p>
{/* Author */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground text-sm font-bold">
{post.author.name.charAt(0)}
</div>
<div>
<p className="text-sm font-semibold text-foreground">{post.author.name}</p>
<p className="text-xs text-muted-foreground">{post.author.role}</p>
</div>
</div>
</Link>
</article>
))}
</div>
{/* Empty state */}
{filteredPosts.length === 0 && (
<div className="text-center py-16">
<p className="text-muted-foreground">No posts found in this category.</p>
</div>
)}
</main>
</div>
</div>
);
}

View File

@ -1,11 +1,31 @@
/**
* Public Contact Page
* Public Support & Contact Page
*
* Contact form for unauthenticated users.
* Combined FAQ, contact options, and contact form for unauthenticated users.
*/
import type { Metadata } from "next";
import { PublicContactView } from "@/features/support/views/PublicContactView";
export const metadata: Metadata = {
title: "Contact Us - English Support for Expats | Assist Solutions",
description:
"Need help? Our English-speaking team is here for you. Call 0120-660-470 (toll-free in Japan), chat, or email. No Japanese required.",
keywords: [
"English support Japan",
"IT help expats Tokyo",
"contact Assist Solutions",
"bilingual support Japan",
"English customer service",
],
openGraph: {
title: "Contact Us - English Support | Assist Solutions",
description:
"Questions about internet, mobile, or IT services? Our English-speaking team is ready to help. Toll-free: 0120-660-470",
type: "website",
},
};
export default function ContactPage() {
return <PublicContactView />;
}

View File

@ -1,11 +1,11 @@
/**
* Public Support Page
* Public Help Page
*
* FAQ and help center for unauthenticated users.
* Redirects to the combined Support & Contact page.
*/
import { PublicSupportView } from "@/features/support/views/PublicSupportView";
import { redirect } from "next/navigation";
export default function PublicSupportPage() {
return <PublicSupportView />;
export default function PublicHelpPage() {
redirect("/contact");
}

View File

@ -1,5 +1,27 @@
import type { Metadata } from "next";
import { PublicLandingView } from "@/features/landing-page";
export const metadata: Metadata = {
title: "Assist Solutions - Internet, Mobile & IT Services for Expats in Japan",
description:
"One stop IT solution for Japan's international community. Reliable fiber internet, mobile SIM cards, VPN, TV services and bilingual tech support since 2002.",
keywords: [
"internet Japan",
"expat internet Tokyo",
"SIM card Japan",
"English support IT",
"fiber optic Japan",
"VPN Japan",
],
openGraph: {
title: "Assist Solutions - IT Services for Expats in Japan",
description:
"Reliable internet, mobile, VPN and tech support with English service. Serving Japan's international community since 2002.",
type: "website",
locale: "en_US",
},
};
export default function PublicHomePage() {
return <PublicLandingView />;
}

View File

@ -1,17 +1,37 @@
import type { Metadata } from "next";
import { Button } from "@/components/atoms";
import { Server, Monitor, Wrench, Globe } from "lucide-react";
export const metadata: Metadata = {
title: "IT Solutions for International Businesses in Japan | Assist Solutions",
description:
"Enterprise IT for foreign companies in Japan. Dedicated internet, office networks, data center hosting, all with bilingual support. We understand international business needs.",
keywords: [
"IT for foreign companies Japan",
"international business IT Tokyo",
"bilingual IT support Japan",
"office network setup foreigners",
"enterprise IT English support",
],
openGraph: {
title: "Business IT for International Companies - Assist Solutions",
description:
"Enterprise IT with bilingual support. Dedicated internet, office networks, and data center services for foreign companies in Japan.",
type: "website",
},
};
export default function BusinessSolutionsPage() {
return (
<div className="max-w-6xl mx-auto px-4">
{/* Header */}
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
Business Solutions
IT for International Businesses
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
We provide comprehensive business solutions including DIA (Dedicated Internet Access) with
SLA and bandwidth guarantees to ensure your business stays connected.
Running an international company in Japan? We provide enterprise IT with bilingual
support, so your team can focus on business, not navigating Japanese tech providers.
</p>
</div>
@ -23,10 +43,9 @@ export default function BusinessSolutionsPage() {
</div>
<h2 className="text-2xl font-bold text-foreground mb-4">Office LAN Setup</h2>
<p className="text-muted-foreground leading-relaxed mb-6">
Whether you are upgrading your current LAN for greater bandwidth and reliability or
installing a new LAN for a new facility, Assist Solutions will ensure you make informed
decisions. From cable installation and data switches to configuration of routers and
firewalls, we help you determine a cost-effective and reliable way to do this.
Setting up a new office or upgrading your network? We handle everything in English. from
planning to installation. Cable runs, switches, routers, and firewalls configured by
bilingual technicians who understand international business needs.
</p>
</div>
@ -37,10 +56,9 @@ export default function BusinessSolutionsPage() {
</div>
<h2 className="text-2xl font-bold text-foreground mb-4">Onsite & Remote Tech Support</h2>
<p className="text-muted-foreground leading-relaxed mb-6">
We provide onsite and remote support to make sure your network is up and running as
quickly as possible. Assist Solutions can help with your IT needs so you can grow your
business with ease and stability. From computer networks to phone and printer
installations, our team will complete your project to your highest satisfaction.
IT issues don&apos;t wait, and neither do we. Our English-speaking technicians provide
fast onsite and remote support for your business. Network problems, hardware issues,
software setup. We keep your operations running smoothly.
</p>
</div>
@ -53,10 +71,9 @@ export default function BusinessSolutionsPage() {
Dedicated Internet Access (DIA)
</h2>
<p className="text-muted-foreground leading-relaxed mb-6">
Dedicated Internet Access is designed for businesses that need greater Internet capacity
and a dedicated connection between their existing Local Area Network (LAN) and the
public Internet. We are able to provide a bandwidth guarantee with a service level
agreement depending on what is most suitable for your business.
Need guaranteed bandwidth for your business? Our Dedicated Internet Access provides
enterprise-grade connectivity with SLA guarantees. Perfect for companies requiring
reliable, high-capacity connections with English contracts and support.
</p>
</div>
@ -67,10 +84,9 @@ export default function BusinessSolutionsPage() {
</div>
<h2 className="text-2xl font-bold text-foreground mb-4">Data Center Service</h2>
<p className="text-muted-foreground leading-relaxed mb-6">
Our Data Center Service provides high-quality data center facilities in Equinix (Tokyo
Tennozu Isle) and GDC (Gotenyama) and many value-added network services to help
establish stable infrastructure platforms. This improves both reliability and efficiency
in your company.
Host your infrastructure in world-class Tokyo data centers (Equinix, GDC Gotenyama). We
provide colocation and managed services with English support, making it easy for
international companies to establish reliable IT infrastructure in Japan.
</p>
</div>
</div>
@ -78,13 +94,15 @@ export default function BusinessSolutionsPage() {
{/* CTA */}
<div className="text-center py-12 bg-muted/20 rounded-3xl mb-16">
<h2 className="text-2xl font-bold text-foreground mb-4">
Interested in our Business Solutions?
Let&apos;s Talk About Your IT Needs
</h2>
<p className="text-muted-foreground mb-8 max-w-2xl mx-auto">
Contact us today to discuss your requirements and how we can help your business grow.
Running an international business in Japan comes with unique challenges. We&apos;ve been
helping foreign companies navigate Japanese IT for over 20 years. Let&apos;s discuss how
we can support your operations.
</p>
<Button as="a" href="/contact" size="lg">
Contact Us
Get in Touch
</Button>
</div>
</div>

View File

@ -4,9 +4,29 @@
* Displays internet plans for unauthenticated users.
*/
import type { Metadata } from "next";
import { PublicInternetPlansView } from "@/features/services/views/PublicInternetPlans";
import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices";
export const metadata: Metadata = {
title: "Internet for Expats in Japan - Fiber Optic with English Support | Assist Solutions",
description:
"High-speed NTT fiber internet with full English support. No Japanese required for signup. We handle the paperwork, you enjoy fast internet. Serving expats since 2002.",
keywords: [
"internet for expats Japan",
"English internet service Tokyo",
"NTT fiber foreigners",
"10Gbps internet expats",
"no Japanese internet signup",
],
openGraph: {
title: "Internet for Expats - Assist Solutions",
description:
"High-speed fiber internet with English support. No Japanese required. We handle everything.",
type: "website",
},
};
export default function PublicInternetPlansPage() {
return (
<>

View File

@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/atoms";
import { Users, Monitor, Tv, Headset, ChevronDown } from "lucide-react";
export function OnsiteSupportContent() {
return (
<div className="max-w-6xl mx-auto px-4">
{/* Header */}
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
Tech Help in English, At Your Door
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Need help with your router, computer, or home network? Our English-speaking technicians
come to your home or office to solve tech problems, explained in a language you
understand.
</p>
</div>
{/* Main Services */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-20">
<div className="space-y-6">
<h2 className="text-3xl font-bold text-foreground">We Come to You</h2>
<p className="text-muted-foreground leading-relaxed">
Living in Japan without strong Japanese skills can make tech problems frustrating.
That&apos;s where we come in. Our English-speaking technicians visit your home or office
to help with setup, troubleshooting, and configuration.
</p>
<p className="text-muted-foreground leading-relaxed">
For quick fixes, we also offer remote support. We connect to your device securely over
the internet to diagnose and resolve issues without a home visit.
</p>
<div className="pt-4">
<Button as="a" href="/contact" size="lg">
Request Support
</Button>
</div>
</div>
<div className="relative h-[300px] bg-muted/30 rounded-2xl overflow-hidden flex items-center justify-center">
<Users className="h-32 w-32 text-muted-foreground/20" />
</div>
</div>
{/* Pricing Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-20">
{/* Onsite Network & Computer Support */}
<div className="bg-card rounded-2xl border border-border/60 p-6 shadow-sm flex flex-col">
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<Monitor className="h-6 w-6" />
</div>
<h3 className="text-lg font-bold text-foreground mb-2">
Onsite Network & Computer Support
</h3>
<div className="mt-auto pt-4">
<div className="text-sm text-muted-foreground mb-1">Basic Service Fee</div>
<div className="text-3xl font-bold text-foreground">15,000 JPY</div>
</div>
</div>
{/* Remote Support */}
<div className="bg-card rounded-2xl border border-border/60 p-6 shadow-sm flex flex-col">
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<Headset className="h-6 w-6" />
</div>
<h3 className="text-lg font-bold text-foreground mb-2">
Remote Network & Computer Support
</h3>
<div className="mt-auto pt-4">
<div className="text-sm text-muted-foreground mb-1">Basic Service Fee</div>
<div className="text-3xl font-bold text-foreground">5,000 JPY</div>
</div>
</div>
{/* Onsite TV Support */}
<div className="bg-card rounded-2xl border border-border/60 p-6 shadow-sm flex flex-col">
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<Tv className="h-6 w-6" />
</div>
<h3 className="text-lg font-bold text-foreground mb-2">Onsite TV Support Service</h3>
<div className="mt-auto pt-4">
<div className="text-sm text-muted-foreground mb-1">Basic Service Fee</div>
<div className="text-3xl font-bold text-foreground">15,000 JPY</div>
</div>
</div>
</div>
{/* FAQ Section */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-foreground mb-6 text-center">
Frequently Asked Questions
</h2>
<div className="space-y-4 max-w-3xl mx-auto">
<FaqItem
question="My home requires multiple Wi-Fi routers. Would you be able to assist with this?"
answer={
<>
Yes, the Assist Solutions technical team is able to visit your residence for device
set up including Wi-Fi routers, printers, Apple TVs etc.
<br />
<br />
Our tech consulting team will be able to make suggestions based on your residence
layout and requirements. Please contact us at info@asolutions.co.jp for a free
consultation.
</>
}
/>
<FaqItem
question="I am already subscribed to a different Internet provider but require more Wi-Fi coverage. Would I be able to just opt for the Onsite Support service without switching over my entire home Internet service?"
answer="Yes, we are able to offer the Onsite Support service as a standalone service."
/>
<FaqItem
question="Do you offer this service outside of Tokyo?"
answer={
<>
Our In-Home Technical Assistance service can be provided in Tokyo, Saitama and
Kanagawa prefecture.
<br />
<br />
*Please note that this service may not available in some areas within the above
prefectures.
<br />
For more information, please contact us at info@asolutions.co.jp
</>
}
/>
</div>
</div>
{/* CTA */}
<div className="text-center py-12 bg-muted/20 rounded-3xl">
<h2 className="text-2xl font-bold text-foreground mb-4">
Tech Problems? We Speak Your Language.
</h2>
<p className="text-muted-foreground mb-6 max-w-xl mx-auto">
Don&apos;t struggle with Japanese-only support lines. Get help from technicians who
explain things clearly in English.
</p>
<Button as="a" href="/contact" size="lg">
Request Support
</Button>
</div>
</div>
);
}
/**
* FAQ Item component with expand/collapse functionality
*/
function FaqItem({ question, answer }: { question: string; answer: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="border border-border rounded-xl overflow-hidden bg-card">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-start justify-between gap-3 p-4 text-left hover:bg-muted/50 transition-colors"
>
<span className="font-medium text-foreground">{question}</span>
<ChevronDown
className={`w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</button>
{isOpen && (
<div className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed">{answer}</div>
)}
</div>
);
}

View File

@ -1,142 +1,25 @@
import { Button } from "@/components/atoms";
import { Users, Monitor, Tv, Headset } from "lucide-react";
import type { Metadata } from "next";
import { OnsiteSupportContent } from "./OnsiteSupportContent";
export const metadata: Metadata = {
title: "English Tech Support at Home - Tokyo & Surrounding Areas | Assist Solutions",
description:
"Tech help in English at your door. Wi-Fi, computers, networks. Our bilingual technicians solve problems and explain things clearly. Tokyo, Saitama, Kanagawa.",
keywords: [
"English tech support Tokyo",
"IT support expats Japan",
"bilingual computer help",
"home network setup foreigners",
"English speaking IT Japan",
],
openGraph: {
title: "English Tech Support at Home - Assist Solutions",
description:
"Bilingual technicians for home and office IT support. We explain things in English.",
type: "website",
},
};
export default function OnsiteSupportPage() {
return (
<div className="max-w-6xl mx-auto px-4">
{/* Header */}
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
Onsite Support
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
We dispatch our skillful in-house tech staff to your residence or office for your needs.
</p>
</div>
{/* Main Services */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-20">
<div className="space-y-6">
<h2 className="text-3xl font-bold text-foreground">Need Our Technical Support?</h2>
<p className="text-muted-foreground leading-relaxed">
We can provide you with on-site technical support service. If you would like for our
technicians to visit your residence and provide technical assistance, please let us
know.
</p>
<p className="text-muted-foreground leading-relaxed">
We also provide "Remote Access Services" which allows our technicians to do support via
Remote Access Software over the Internet connection to fix up the issue (depends on what
the issue is).
</p>
<div className="pt-4">
<Button as="a" href="/contact" size="lg">
Request Support
</Button>
</div>
</div>
<div className="relative h-[300px] bg-muted/30 rounded-2xl overflow-hidden flex items-center justify-center">
<Users className="h-32 w-32 text-muted-foreground/20" />
</div>
</div>
{/* Pricing Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-20">
{/* Onsite Network & Computer Support */}
<div className="bg-card rounded-2xl border border-border/60 p-6 shadow-sm flex flex-col">
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<Monitor className="h-6 w-6" />
</div>
<h3 className="text-lg font-bold text-foreground mb-2">
Onsite Network & Computer Support
</h3>
<div className="mt-auto pt-4">
<div className="text-sm text-muted-foreground mb-1">Basic Service Fee</div>
<div className="text-3xl font-bold text-foreground">15,000 JPY</div>
</div>
</div>
{/* Remote Support */}
<div className="bg-card rounded-2xl border border-border/60 p-6 shadow-sm flex flex-col">
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<Headset className="h-6 w-6" />
</div>
<h3 className="text-lg font-bold text-foreground mb-2">
Remote Network & Computer Support
</h3>
<div className="mt-auto pt-4">
<div className="text-sm text-muted-foreground mb-1">Basic Service Fee</div>
<div className="text-3xl font-bold text-foreground">5,000 JPY</div>
</div>
</div>
{/* Onsite TV Support */}
<div className="bg-card rounded-2xl border border-border/60 p-6 shadow-sm flex flex-col">
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<Tv className="h-6 w-6" />
</div>
<h3 className="text-lg font-bold text-foreground mb-2">Onsite TV Support Service</h3>
<div className="mt-auto pt-4">
<div className="text-sm text-muted-foreground mb-1">Basic Service Fee</div>
<div className="text-3xl font-bold text-foreground">15,000 JPY</div>
</div>
</div>
</div>
{/* FAQ Section */}
<div className="max-w-4xl mx-auto mb-16">
<h2 className="text-3xl font-bold text-foreground mb-8 text-center">
Frequently Asked Questions
</h2>
<div className="space-y-8">
<div className="bg-card rounded-2xl border border-border/60 p-8">
<h3 className="text-xl font-bold text-foreground mb-3">
My home requires multiple Wi-Fi routers. Would you be able to assist with this?
</h3>
<p className="text-muted-foreground leading-relaxed">
Yes, the Assist Solutions technical team is able to visit your residence for device
set up including Wi-Fi routers, printers, Apple TVs etc. Our tech consulting team will
be able to make suggestions based on your residence layout and requirements. Please
contact us for a free consultation.
</p>
</div>
<div className="bg-card rounded-2xl border border-border/60 p-8">
<h3 className="text-xl font-bold text-foreground mb-3">
I am already subscribed to a different Internet provider but require more Wi-Fi
coverage. Would I be able to just opt for the Onsite Support service without switching
over my entire home Internet service?
</h3>
<p className="text-muted-foreground leading-relaxed">
Yes, we are able to offer the Onsite Support service as a standalone service.
</p>
</div>
<div className="bg-card rounded-2xl border border-border/60 p-8">
<h3 className="text-xl font-bold text-foreground mb-3">
Do you offer this service outside of Tokyo?
</h3>
<div className="text-muted-foreground leading-relaxed">
<p className="mb-2">
Our In-Home Technical Assistance service can be provided in Tokyo, Saitama and
Kanagawa prefecture.
</p>
<p className="text-sm italic">
*Please note that this service may not available in some areas within the above
prefectures. For more information, please contact us.
</p>
</div>
</div>
</div>
</div>
{/* CTA */}
<div className="text-center py-12 bg-muted/20 rounded-3xl">
<h2 className="text-2xl font-bold text-foreground mb-4">Ready to get started?</h2>
<Button as="a" href="/contact" size="lg">
Contact Us for Support
</Button>
</div>
</div>
);
return <OnsiteSupportContent />;
}

View File

@ -1,5 +1,44 @@
import { PublicServicesOverview } from "@/features/services/views/PublicServicesOverview";
import type { Metadata } from "next";
import { ServicesGrid } from "@/features/services/components/common/ServicesGrid";
export default function ServicesPage() {
return <PublicServicesOverview />;
export const metadata: Metadata = {
title: "Services for Expats in Japan - Internet, Mobile, VPN & IT Support | Assist Solutions",
description:
"IT services designed for foreigners in Japan. Fiber internet, SIM cards, VPN access, and tech support, all with full English support. No Japanese required.",
keywords: [
"internet service Japan expats",
"SIM card foreigners Tokyo",
"VPN service Japan",
"IT support expats Japan",
"English internet service Japan",
],
openGraph: {
title: "Services for Expats - Assist Solutions",
description:
"Internet, mobile, VPN and tech support services with full English support for expats in Japan.",
type: "website",
},
};
interface ServicesPageProps {
basePath?: string;
}
export default function ServicesPage({ basePath = "/services" }: ServicesPageProps) {
return (
<div className="max-w-6xl mx-auto px-4">
{/* Header */}
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
Services for Expats in Japan
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Tired of navigating Japanese-only websites and contracts? We provide internet, mobile, and
IT services with full English support. No Japanese required.
</p>
</div>
<ServicesGrid basePath={basePath} />
</div>
);
}

View File

@ -4,9 +4,29 @@
* Displays SIM plans for unauthenticated users.
*/
import type { Metadata } from "next";
import { PublicSimPlansView } from "@/features/services/views/PublicSimPlans";
import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices";
export const metadata: Metadata = {
title: "SIM Cards for Foreigners in Japan - No Hanko Required | Assist Solutions",
description:
"SIM cards for expats on Japan's best network. Foreign credit cards accepted, no hanko needed, English support. First month free. Get connected in days, not weeks.",
keywords: [
"SIM card foreigners Japan",
"mobile service expats Tokyo",
"no hanko SIM Japan",
"foreign credit card SIM",
"English SIM service Japan",
],
openGraph: {
title: "SIM Cards for Foreigners - Assist Solutions",
description:
"Mobile plans for expats. Foreign credit cards accepted, no hanko. English support included.",
type: "website",
},
};
export default function PublicSimPlansPage() {
return (
<>

View File

@ -1,816 +0,0 @@
import { Button } from "@/components/atoms";
import {
Tv,
Film,
Music,
Trophy,
Newspaper,
Sparkles,
MoreHorizontal,
GraduationCap,
Globe,
} from "lucide-react";
// Constants for duplicate strings
const CATEGORY = {
MOVIE: "Movie",
SPORTS: "Sports",
MUSIC: "Music",
KIDS: "Kids",
FOREIGN_DRAMA: "Foreign Drama",
DOCUMENTARY: "Documentary",
NEWS_BUSINESS: "News & Business",
ENTERTAINMENT: "Entertainment",
OTHERS: "Others",
} as const;
const HOUSING_TYPE = {
APARTMENT: "Apartment Type",
HOME: "Home Type",
STANDARD: "Standard",
} as const;
const PRICE = {
FREE: "Free",
TAX_NOTE: "*Prices shown above are including tax (10%)",
} as const;
const PACKAGE_TITLE = {
BASIC: "Basic Package",
BIG: "Big Package",
STANDARD: "Standard Package",
} as const;
// Frequently used channels
const CHANNEL = {
THE_CINEMA_HD: "The Cinema HD",
MOVIE_PLUS_HD: "Movie Plus HD",
CHANNEL_NECO_HD: "Channel NECO HD",
JAPANESE_MOVIE_HD: "Japanese Movie HD",
FOX_MOVIE_PREMIUM_HD: "Fox Movie Premium HD",
CINEFIL_WOWOW_HD: "Cinefil Wowow HD",
NTV_G_HD: "NTV G HD",
EX_SPORTS: "EX Sports",
DANCE_CHANNEL: "Dance Channel",
GOLF_NETWORK_HD: "Golf Network HD",
GAORA_HD: "GAORA HD",
J_SPORTS_1_HD: "J Sports 1 HD",
J_SPORTS_2_HD: "J Sports 2 HD",
J_SPORTS_3_HD: "J Sports 3 HD",
SKY_A_SPORTS_HD: "Sky A Sports HD",
MUSIC_JAPAN_TV_HD: "Music Japan TV HD",
MTV_HD: "MTV HD",
MUSIC_ON_TV_HD: "Music ON! TV HD",
SPACE_SHOWER_TV_HD: "Space Shower TV HD",
SPACE_SHOWER_TV_PLUS_HD: "Space Shower TV Plus HD",
KAYOU_POPS_HD: "Kayou Pops HD",
MUSIC_AIR_HD: "Music Air HD",
MUSIC_GRAFFITI_TV: "Music Graffiti TV",
CARTOON_NETWORK_HD: "Cartoon Network HD",
DISNEY_CHANNEL_HD: "Disney Channel HD",
DISNEY_XD_HD: "Disney XD HD",
KIDS_STATION_HD: "Kids Station HD",
ANIMAX_HD: "Animax HD",
LALA_HD: "Lala HD",
ASIA_DRAMATIC_TV_HD: "Asia Dramatic TV HD",
KBS_WORLD_HD: "KBS World HD",
SUPER_DRAMA_TV_HD: "Super Drama TV HD",
AXN_MYSTERY_HD: "AXN Mystery HD",
AXN_HD: "AXN HD",
FOX_HD: "FOX HD",
FOX_CLASSICS_HD: "FOX CLASSICS HD",
HISTORY_CHANNEL_HD: "History Channel HD",
NATIONAL_GEOGRAPHIC_HD: "National Geographic HD",
DISCOVERY_HD: "Discovery HD",
NAT_GEO_WILD_HD: "Nat Geo Wild HD",
ANIMAL_PLANET_HD: "Animal Planet HD",
TBS_NEWS_BIRD_HD: "TBS News Bird HD",
NIKKEI_CNBC_HD: "Nikkei CNBC HD",
BBC_WORLD_NEWS_HD: "BBC World News HD",
CNNJ_HD: "CNNj HD",
NTV_NEWS_24: "NTV News 24",
FOX_SPORTS_HD: "Fox Sports HD",
FUJITV_ONE_HD: "FujiTV One HD",
FUJITV_TWO_HD: "FujiTV Two HD",
TBS_CHANNEL_1_HD: "TBS Channel 1 HD",
TBS_CHANNEL_2_HD: "TBS Channel 2 HD",
TV_ASAHI_CHANNEL_1_HD: "TV Asahi Channel 1 HD",
TV_ASAHI_CHANNEL_2_HD: "TV Asahi Channel 2 HD",
FAMILY_THEATER_HD: "Family Theater HD",
HOME_DRAMA_HD: "Home Drama HD",
SAMURAI_DRAMA_HD: "Samurai Drama HD",
TABI_CHANNEL_HD: "TABI Channel HD",
TSURI_VISION_HD: "Tsuri Vision HD",
IGO_SHOGI_CHANNEL_HD: "Igo/Shogi Channel HD",
MONDO_TV_HD: "Mondo TV HD",
DISNEY_JUNIOR: "Disney Junior",
CHANNEL_GINGA: "Channel Ginga",
NTV_PLUS: "NTV Plus",
SPACE_SHOWER_TV: "Space Shower TV",
MUSIC_AIR: "Music Air",
CARTOON_NETWORK: "Cartoon Network",
NATIONAL_GEOGRAPHIC: "National Geographic",
IGO_SHOGI_CHANNEL: "Igo/Shogi Channel",
CHANNEL_GINGA_HD: "Channel Ginga HD",
NHK_WORLD_JAPAN: "NHK World Japan",
} as const;
// Service data
const SKY_PERFECTV_PREMIUM_HIKARI_DATA = {
title: "Sky PerfecTV Premium Hikari (Optical Fiber TV)",
fees: [
{ type: HOUSING_TYPE.APARTMENT, initial: PRICE.FREE, monthly: "4,567 JPY" },
{
type: HOUSING_TYPE.HOME,
initial: "14,630 JPY (6,680 JPY if simultaneous installation)",
monthly: "5,392 JPY",
},
],
channels: {
movie: [
CHANNEL.THE_CINEMA_HD,
CHANNEL.MOVIE_PLUS_HD,
CHANNEL.CHANNEL_NECO_HD,
CHANNEL.JAPANESE_MOVIE_HD,
CHANNEL.FOX_MOVIE_PREMIUM_HD,
CHANNEL.CINEFIL_WOWOW_HD,
CHANNEL.NTV_G_HD,
CHANNEL.EX_SPORTS,
CHANNEL.DANCE_CHANNEL,
],
sports: [
CHANNEL.GOLF_NETWORK_HD,
CHANNEL.GAORA_HD,
CHANNEL.J_SPORTS_1_HD,
CHANNEL.J_SPORTS_2_HD,
CHANNEL.J_SPORTS_3_HD,
CHANNEL.SKY_A_SPORTS_HD,
],
music: [
CHANNEL.MUSIC_JAPAN_TV_HD,
CHANNEL.MTV_HD,
CHANNEL.MUSIC_ON_TV_HD,
CHANNEL.SPACE_SHOWER_TV_HD,
CHANNEL.SPACE_SHOWER_TV_PLUS_HD,
CHANNEL.KAYOU_POPS_HD,
CHANNEL.MUSIC_AIR_HD,
CHANNEL.MUSIC_GRAFFITI_TV,
],
kids: [
CHANNEL.CARTOON_NETWORK_HD,
CHANNEL.DISNEY_CHANNEL_HD,
CHANNEL.DISNEY_XD_HD,
CHANNEL.KIDS_STATION_HD,
CHANNEL.ANIMAX_HD,
CHANNEL.LALA_HD,
CHANNEL.ASIA_DRAMATIC_TV_HD,
CHANNEL.KBS_WORLD_HD,
],
foreignDrama: [
CHANNEL.SUPER_DRAMA_TV_HD,
CHANNEL.AXN_MYSTERY_HD,
CHANNEL.AXN_HD,
CHANNEL.FOX_HD,
CHANNEL.FOX_CLASSICS_HD,
],
documentary: [
CHANNEL.HISTORY_CHANNEL_HD,
CHANNEL.NATIONAL_GEOGRAPHIC_HD,
CHANNEL.DISCOVERY_HD,
CHANNEL.NAT_GEO_WILD_HD,
CHANNEL.ANIMAL_PLANET_HD,
],
newsBusiness: [
CHANNEL.TBS_NEWS_BIRD_HD,
CHANNEL.NIKKEI_CNBC_HD,
CHANNEL.BBC_WORLD_NEWS_HD,
CHANNEL.CNNJ_HD,
CHANNEL.NTV_NEWS_24,
"SORA Weather Channel",
"E-tenki.net",
"Entametele HD",
"Nittele Plus HD",
],
entertainment: [
CHANNEL.FOX_SPORTS_HD,
CHANNEL.FUJITV_ONE_HD,
CHANNEL.FUJITV_TWO_HD,
CHANNEL.TBS_CHANNEL_1_HD,
CHANNEL.TBS_CHANNEL_2_HD,
CHANNEL.TV_ASAHI_CHANNEL_1_HD,
CHANNEL.TV_ASAHI_CHANNEL_2_HD,
],
others: [
CHANNEL.MONDO_TV_HD,
CHANNEL.FAMILY_THEATER_HD,
CHANNEL.HOME_DRAMA_HD,
CHANNEL.SAMURAI_DRAMA_HD,
CHANNEL.TABI_CHANNEL_HD,
CHANNEL.TSURI_VISION_HD,
CHANNEL.IGO_SHOGI_CHANNEL_HD,
],
},
};
const SKY_PERFECTV_PREMIUM_SATELLITE_DATA = {
title: "Sky PerfecTV Premium (Satellite)",
fees: [{ type: HOUSING_TYPE.STANDARD, initial: PRICE.FREE, monthly: "4,514 JPY" }],
channels: {
movie: [
"Imagica BS HD",
CHANNEL.THE_CINEMA_HD,
CHANNEL.MOVIE_PLUS_HD,
CHANNEL.CHANNEL_NECO_HD,
CHANNEL.JAPANESE_MOVIE_HD,
CHANNEL.FOX_MOVIE_PREMIUM_HD,
CHANNEL.CINEFIL_WOWOW_HD,
],
sports: [
CHANNEL.J_SPORTS_3_HD,
CHANNEL.SKY_A_SPORTS_HD,
CHANNEL.NTV_G_HD,
CHANNEL.EX_SPORTS,
CHANNEL.DANCE_CHANNEL,
],
music: [
CHANNEL.MUSIC_JAPAN_TV_HD,
CHANNEL.MTV_HD,
CHANNEL.MUSIC_ON_TV_HD,
CHANNEL.SPACE_SHOWER_TV_HD,
CHANNEL.SPACE_SHOWER_TV_PLUS_HD,
CHANNEL.KAYOU_POPS_HD,
CHANNEL.MUSIC_AIR_HD,
CHANNEL.MUSIC_GRAFFITI_TV,
],
kids: [
CHANNEL.CARTOON_NETWORK_HD,
CHANNEL.DISNEY_CHANNEL_HD,
CHANNEL.DISNEY_XD_HD,
CHANNEL.KIDS_STATION_HD,
CHANNEL.ANIMAX_HD,
CHANNEL.DISNEY_JUNIOR,
CHANNEL.ASIA_DRAMATIC_TV_HD,
CHANNEL.KBS_WORLD_HD,
],
foreignDrama: [
CHANNEL.SUPER_DRAMA_TV_HD,
CHANNEL.AXN_MYSTERY_HD,
CHANNEL.AXN_HD,
CHANNEL.FOX_HD,
CHANNEL.FOX_CLASSICS_HD,
CHANNEL.LALA_HD,
],
documentary: [
CHANNEL.HISTORY_CHANNEL_HD,
CHANNEL.NATIONAL_GEOGRAPHIC_HD,
CHANNEL.DISCOVERY_HD,
CHANNEL.NAT_GEO_WILD_HD,
CHANNEL.ANIMAL_PLANET_HD,
],
newsBusiness: [
CHANNEL.TBS_NEWS_BIRD_HD,
CHANNEL.NIKKEI_CNBC_HD,
CHANNEL.BBC_WORLD_NEWS_HD,
CHANNEL.CNNJ_HD,
CHANNEL.NTV_NEWS_24,
"CCTV Daifu",
"SORA Weather Channel",
"Entametele HD",
"NTV Plus HD",
CHANNEL.CHANNEL_GINGA,
],
entertainment: [
CHANNEL.FOX_SPORTS_HD,
CHANNEL.FUJITV_ONE_HD,
CHANNEL.FUJITV_TWO_HD,
CHANNEL.TBS_CHANNEL_1_HD,
CHANNEL.TBS_CHANNEL_2_HD,
CHANNEL.TV_ASAHI_CHANNEL_1_HD,
CHANNEL.TV_ASAHI_CHANNEL_2_HD,
"Yose Channel",
],
others: [
"IGO & Shogi",
"Mondo 21 HD",
CHANNEL.FAMILY_THEATER_HD,
CHANNEL.HOME_DRAMA_HD,
CHANNEL.SAMURAI_DRAMA_HD,
CHANNEL.TABI_CHANNEL_HD,
"Railyway Channel HD",
],
},
};
const SKY_PERFECTV_SATELLITE_DATA = {
title: "Sky PerfecTV (Satellite)",
fees: [
{ type: HOUSING_TYPE.APARTMENT, initial: PRICE.FREE, monthly: "4,389 JPY" },
{
type: HOUSING_TYPE.HOME,
initial: "11,330 JPY (6,680 JPY if simultaneous installation)",
monthly: "5,214 JPY",
},
],
channels: {
movie: [
CHANNEL.MOVIE_PLUS_HD,
"Japanese Movie HD (BS)",
"The Cinema",
"Channel NECO",
CHANNEL.CINEFIL_WOWOW_HD,
],
sports: ["Nittele G HD", CHANNEL.SKY_A_SPORTS_HD, "GAORA Sports HD", CHANNEL.FOX_SPORTS_HD],
music: [
CHANNEL.MTV_HD,
CHANNEL.MUSIC_ON_TV_HD,
CHANNEL.SPACE_SHOWER_TV,
CHANNEL.MUSIC_AIR,
"Space Shower TV Plus",
"Kayo Pops",
],
kids: [
CHANNEL.KIDS_STATION_HD,
"BS Animax HD (BS)",
"Disney (BS)",
CHANNEL.CARTOON_NETWORK,
CHANNEL.DISNEY_JUNIOR,
],
foreignDrama: [CHANNEL.LALA_HD, CHANNEL.SUPER_DRAMA_TV_HD, "AXN", "AXN Mystery", "FOX"],
documentary: ["History Channel", CHANNEL.NATIONAL_GEOGRAPHIC, "Discovery", "Animal Planet"],
newsBusiness: [
"TBS News Bird",
"BBC World News",
"CNNj",
CHANNEL.NTV_NEWS_24,
CHANNEL.TBS_CHANNEL_1_HD,
"TBS Channel 2",
CHANNEL.NTV_PLUS,
CHANNEL.CHANNEL_GINGA,
],
entertainment: [
"Fuji TV One HD",
"Fuji TV Two HD",
CHANNEL.TV_ASAHI_CHANNEL_1_HD,
CHANNEL.TV_ASAHI_CHANNEL_2_HD,
CHANNEL.IGO_SHOGI_CHANNEL,
CHANNEL.MONDO_TV_HD,
],
others: [
"Tsuri Vision HD (BS)",
"Family Gekijo HD",
CHANNEL.SAMURAI_DRAMA_HD,
"Home Drama Channel",
],
},
};
const ITSCOM_DATA = {
title: "iTSCOM (CATV)",
fees: [{ type: "Big Package", initial: "11,000 JPY", monthly: "5,280 JPY" }],
channels: {
movie: [
CHANNEL.MOVIE_PLUS_HD,
CHANNEL.JAPANESE_MOVIE_HD,
CHANNEL.CHANNEL_NECO_HD,
"FOX Movie Premium HD",
],
sports: [
CHANNEL.GOLF_NETWORK_HD,
"Nittele G HD",
CHANNEL.SKY_A_SPORTS_HD,
CHANNEL.GAORA_HD,
CHANNEL.J_SPORTS_1_HD,
CHANNEL.J_SPORTS_2_HD,
CHANNEL.J_SPORTS_3_HD,
],
music: [CHANNEL.MTV_HD, CHANNEL.MUSIC_ON_TV_HD, CHANNEL.SPACE_SHOWER_TV, CHANNEL.MUSIC_AIR],
kids: [
CHANNEL.ANIMAX_HD,
CHANNEL.KIDS_STATION_HD,
"Disney XD",
CHANNEL.CARTOON_NETWORK,
"Baby TV",
CHANNEL.DISNEY_JUNIOR,
],
foreignDrama: [
CHANNEL.AXN_HD,
CHANNEL.FOX_HD,
CHANNEL.ASIA_DRAMATIC_TV_HD,
CHANNEL.SUPER_DRAMA_TV_HD,
CHANNEL.AXN_MYSTERY_HD,
CHANNEL.FAMILY_THEATER_HD,
],
documentary: [
CHANNEL.DISCOVERY_HD,
CHANNEL.NATIONAL_GEOGRAPHIC,
CHANNEL.ANIMAL_PLANET_HD,
CHANNEL.HISTORY_CHANNEL_HD,
],
newsBusiness: [
CHANNEL.BBC_WORLD_NEWS_HD,
CHANNEL.CNNJ_HD,
CHANNEL.NIKKEI_CNBC_HD,
CHANNEL.TBS_NEWS_BIRD_HD,
"TV Asahi Channel 2",
CHANNEL.NTV_NEWS_24,
CHANNEL.NHK_WORLD_JAPAN,
CHANNEL.DISNEY_CHANNEL_HD,
],
entertainment: [
"LaLa TV HD",
CHANNEL.CHANNEL_GINGA_HD,
"Dlife HD",
"FOX Sports HD",
CHANNEL.TBS_CHANNEL_1_HD,
"TBS Channel 2",
CHANNEL.NTV_PLUS,
],
others: [
"Japanet Channel DX HD",
"Jewelly Gem Shopping HD",
CHANNEL.SAMURAI_DRAMA_HD,
"TABI Channel",
"QVC",
"Shop Channel",
CHANNEL.IGO_SHOGI_CHANNEL,
],
},
};
const JCOM_DATA = {
title: "JCOM (CATV)",
fees: [{ type: "Standard Package", initial: "9,900 JPY", monthly: "6,074 JPY" }],
channels: {
movie: [
CHANNEL.MOVIE_PLUS_HD,
CHANNEL.THE_CINEMA_HD,
CHANNEL.CHANNEL_NECO_HD,
CHANNEL.JAPANESE_MOVIE_HD,
"Fox Movies",
CHANNEL.CINEFIL_WOWOW_HD,
"Fox Sports & Entertainment",
],
sports: [
CHANNEL.J_SPORTS_1_HD,
CHANNEL.J_SPORTS_2_HD,
CHANNEL.J_SPORTS_3_HD,
CHANNEL.SKY_A_SPORTS_HD,
CHANNEL.GOLF_NETWORK_HD,
CHANNEL.GAORA_HD,
],
music: [CHANNEL.MTV_HD, CHANNEL.MUSIC_ON_TV_HD, CHANNEL.SPACE_SHOWER_TV, "Kayou Pops"],
kids: [
CHANNEL.ANIMAX_HD,
CHANNEL.KIDS_STATION_HD,
CHANNEL.CARTOON_NETWORK_HD,
"Disney CHannel HD",
CHANNEL.DISNEY_XD_HD,
"Disney Junior HD",
CHANNEL.AXN_MYSTERY_HD,
],
foreignDrama: [
CHANNEL.SUPER_DRAMA_TV_HD,
CHANNEL.FOX_HD,
"FOX Classics HD",
CHANNEL.AXN_HD,
CHANNEL.LALA_HD,
CHANNEL.KBS_WORLD_HD,
],
documentary: [
"Histroy Channel HD",
CHANNEL.NATIONAL_GEOGRAPHIC_HD,
"Discovery Channel HD",
CHANNEL.ANIMAL_PLANET_HD,
],
newsBusiness: [
CHANNEL.TBS_NEWS_BIRD_HD,
CHANNEL.NIKKEI_CNBC_HD,
"TV Asahi Channel 2 HD",
"CNNj",
CHANNEL.NTV_NEWS_24,
CHANNEL.NHK_WORLD_JAPAN,
"KBS World",
CHANNEL.NTV_PLUS,
"Act On TV",
],
entertainment: [
CHANNEL.FUJITV_ONE_HD,
CHANNEL.FUJITV_TWO_HD,
CHANNEL.TBS_CHANNEL_1_HD,
CHANNEL.TBS_CHANNEL_2_HD,
CHANNEL.CHANNEL_GINGA_HD,
"TV Asahi Channel 1",
"Housou University TV",
],
others: [
CHANNEL.FAMILY_THEATER_HD,
CHANNEL.SAMURAI_DRAMA_HD,
"Home Drama Channel HD",
CHANNEL.MONDO_TV_HD,
CHANNEL.TSURI_VISION_HD,
CHANNEL.IGO_SHOGI_CHANNEL,
],
},
};
// CSS class constants
const STYLES = {
cardBorder: "border border-border/60",
cardRounded: "rounded-2xl",
sectionSpacing: "mb-16",
textForeground: "text-foreground",
textMuted: "text-muted-foreground",
leadingRelaxed: "leading-relaxed",
} as const;
// Types
interface Fee {
type: string;
initial: string;
monthly: string;
}
interface ChannelData {
movie: string[];
sports: string[];
music: string[];
kids: string[];
foreignDrama: string[];
documentary: string[];
newsBusiness: string[];
entertainment: string[];
others: string[];
}
interface TVServiceData {
title: string;
fees: Fee[];
channels: ChannelData;
}
// Sub-components
function PageHeader(): React.ReactElement {
return (
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
TV Services
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Providing a variety of options for our customers such as Satellite TV, Cable TV and Optical
Fiber TV.
</p>
</div>
);
}
function ServiceIntro(): React.ReactElement {
return (
<div className="max-w-3xl mx-auto text-center mb-16">
<h2 className="text-2xl font-bold text-foreground mb-4">Service Lineup</h2>
<p className="text-muted-foreground leading-relaxed mb-6">
We are proud to act as agents for Japan's major paid TV service providers, and we will
arrange your services on your behalf (no service fee required for us to arrange your
services). Usually each building has their pre-assigned main TV service providers. To find
out which TV service you can apply for, please feel free to contact us anytime.
</p>
<Button as="a" href="/contact" size="lg">
Check Availability
</Button>
</div>
);
}
function FAQSection(): React.ReactElement {
return (
<div className="max-w-4xl mx-auto mb-16">
<h2 className="text-3xl font-bold text-foreground mb-8 text-center">
Frequently Asked Questions
</h2>
<div className="space-y-6">
<div className={`bg-card ${STYLES.cardRounded} ${STYLES.cardBorder} p-8`}>
<h3 className="text-xl font-bold text-foreground mb-3">
Is Assist Solutions directly providing the TV service?
</h3>
<p className="text-muted-foreground leading-relaxed">
As partners, we are able to refer you to each cable TV company available at your home.
However, once the service starts, the cable TV service itself will be directly provided
by each cable TV company.
</p>
</div>
<div className={`bg-card ${STYLES.cardRounded} ${STYLES.cardBorder} p-8`}>
<h3 className="text-xl font-bold text-foreground mb-3">
Would I be able to choose any cable TV service that Assist Solutions is partnered with?
</h3>
<p className="text-muted-foreground leading-relaxed">
In Japan, most cable TV companies have predetermined service areas. We will be able to
check which services are available for your home. Please contact us for a free
consultation.
</p>
</div>
</div>
</div>
);
}
function CTASection(): React.ReactElement {
return (
<div className="text-center py-12 bg-muted/20 rounded-3xl">
<h2 className="text-2xl font-bold text-foreground mb-4">Find the best TV service for you</h2>
<Button as="a" href="/contact" size="lg">
Contact Us
</Button>
</div>
);
}
function TVServiceSection({
title,
fees,
note,
children,
}: {
title: string;
fees: Fee[];
note?: string;
children?: React.ReactNode;
}): React.ReactElement {
return (
<div className={`${STYLES.cardBorder} ${STYLES.cardRounded} overflow-hidden bg-card shadow-sm`}>
<div className={`bg-primary/5 p-6 ${STYLES.cardBorder.replace("border", "border-b")}`}>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
<Tv className="h-6 w-6" />
</div>
<h2 className="text-2xl font-bold text-foreground">{title}</h2>
</div>
</div>
<div className="p-6 md:p-8">
<div className="mb-8">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
Service Fees
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-muted/50 text-muted-foreground font-medium">
<tr>
<th className="px-4 py-3 rounded-l-lg">Type</th>
<th className="px-4 py-3">Initial Cost</th>
<th className="px-4 py-3 rounded-r-lg">Monthly Cost</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{fees.map((fee, i) => (
<tr key={i}>
<td className="px-4 py-3 font-medium text-foreground">{fee.type}</td>
<td className="px-4 py-3">{fee.initial}</td>
<td className="px-4 py-3">{fee.monthly}</td>
</tr>
))}
</tbody>
</table>
</div>
{note && <p className="text-xs text-muted-foreground mt-2">{note}</p>}
</div>
{children}
</div>
</div>
);
}
function ChannelPackage({
title,
children,
}: {
title: string;
children: React.ReactNode;
}): React.ReactElement {
return (
<div>
<h3 className="text-lg font-bold text-foreground mb-4 border-l-4 border-primary pl-3">
{title}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">{children}</div>
</div>
);
}
function getCategoryIcon(title: string): React.ReactElement {
const iconClass = "h-4 w-4 text-primary";
switch (title) {
case CATEGORY.MOVIE:
return <Film className={iconClass} />;
case CATEGORY.MUSIC:
return <Music className={iconClass} />;
case CATEGORY.SPORTS:
return <Trophy className={iconClass} />;
case CATEGORY.NEWS_BUSINESS:
return <Newspaper className={iconClass} />;
case CATEGORY.ENTERTAINMENT:
case CATEGORY.KIDS:
return <Sparkles className={iconClass} />;
case CATEGORY.FOREIGN_DRAMA:
return <Globe className={iconClass} />;
case CATEGORY.DOCUMENTARY:
return <GraduationCap className={iconClass} />;
case CATEGORY.OTHERS:
default:
return <MoreHorizontal className={iconClass} />;
}
}
function ChannelCategory({
title,
channels,
}: {
title: string;
channels: string[];
}): React.ReactElement {
return (
<div className="bg-muted/30 rounded-lg p-4">
<h4 className="font-semibold text-foreground mb-3 flex items-center gap-2">
{getCategoryIcon(title)}
{title}
</h4>
<ul className="space-y-1">
{channels.map(channel => (
<li key={channel} className="text-sm text-muted-foreground">
{channel}
</li>
))}
</ul>
</div>
);
}
function ServiceChannelPackage({
packageTitle,
channels,
}: {
packageTitle: string;
channels: ChannelData;
}): React.ReactElement {
return (
<ChannelPackage title={packageTitle}>
<ChannelCategory title={CATEGORY.MOVIE} channels={channels.movie} />
<ChannelCategory title={CATEGORY.SPORTS} channels={channels.sports} />
<ChannelCategory title={CATEGORY.MUSIC} channels={channels.music} />
<ChannelCategory title={CATEGORY.KIDS} channels={channels.kids} />
<ChannelCategory title={CATEGORY.FOREIGN_DRAMA} channels={channels.foreignDrama} />
<ChannelCategory title={CATEGORY.DOCUMENTARY} channels={channels.documentary} />
<ChannelCategory title={CATEGORY.NEWS_BUSINESS} channels={channels.newsBusiness} />
<ChannelCategory title={CATEGORY.ENTERTAINMENT} channels={channels.entertainment} />
<ChannelCategory title={CATEGORY.OTHERS} channels={channels.others} />
</ChannelPackage>
);
}
function TVServiceWithChannels({
data,
packageTitle,
}: {
data: TVServiceData;
packageTitle: string;
}): React.ReactElement {
return (
<TVServiceSection title={data.title} fees={data.fees} note={PRICE.TAX_NOTE}>
<ServiceChannelPackage packageTitle={packageTitle} channels={data.channels} />
</TVServiceSection>
);
}
function ServicesList(): React.ReactElement {
return (
<div className="space-y-16 mb-20">
<TVServiceWithChannels
data={SKY_PERFECTV_PREMIUM_HIKARI_DATA}
packageTitle={PACKAGE_TITLE.BASIC}
/>
<TVServiceWithChannels
data={SKY_PERFECTV_PREMIUM_SATELLITE_DATA}
packageTitle={PACKAGE_TITLE.BASIC}
/>
<TVServiceWithChannels
data={SKY_PERFECTV_SATELLITE_DATA}
packageTitle={PACKAGE_TITLE.BASIC}
/>
<TVServiceWithChannels data={ITSCOM_DATA} packageTitle={PACKAGE_TITLE.BIG} />
<TVServiceWithChannels data={JCOM_DATA} packageTitle={PACKAGE_TITLE.STANDARD} />
</div>
);
}
export default function TVServicesPage(): React.ReactElement {
return (
<div className="max-w-6xl mx-auto px-4">
<PageHeader />
<ServiceIntro />
<ServicesList />
<FAQSection />
<CTASection />
</div>
);
}

View File

@ -4,9 +4,28 @@
* Displays VPN plans for unauthenticated users.
*/
import type { Metadata } from "next";
import { PublicVpnPlansView } from "@/features/services/views/PublicVpnPlans";
import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices";
export const metadata: Metadata = {
title: "VPN for Expats - Stream US/UK Content in Japan | Assist Solutions",
description:
"Watch your favorite shows from home while living in Japan. Pre-configured VPN router, just plug in and stream. US and UK servers available.",
keywords: [
"VPN expats Japan",
"stream US content Japan",
"Netflix VPN Tokyo",
"UK streaming Japan",
],
openGraph: {
title: "VPN for Expats in Japan - Assist Solutions",
description:
"Stream your favorite content from home. Pre-configured router, just plug in. US and UK servers.",
type: "website",
},
};
export default function PublicVpnPlansPage() {
return (
<>

View File

@ -1,22 +1,69 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { Sora } from "next/font/google";
import { GeistSans } from "geist/font/sans";
import "./globals.css";
import { QueryProvider } from "@/core/providers";
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
// Display font for headlines and hero text
const sora = Sora({
subsets: ["latin"],
variable: "--font-display",
display: "swap",
weight: ["500", "600", "700", "800"],
});
export const metadata: Metadata = {
title: "Assist Solutions Portal",
description: "Manage your subscriptions, billing, and support with Assist Solutions",
title: {
default: "Assist Solutions - IT Services for Expats in Japan",
template: "%s | Assist Solutions",
},
description:
"One stop IT solution for Japan's international community. Internet, mobile, VPN, and tech support with English service since 2002.",
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp"),
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
locale: "en_US",
siteName: "Assist Solutions",
},
twitter: {
card: "summary_large_image",
},
robots: {
index: true,
follow: true,
},
};
// Organization structured data for rich search results
const organizationJsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: "Assist Solutions Corp.",
alternateName: "Assist Solutions",
url: "https://asolutions.co.jp",
logo: "https://portal.asolutions.co.jp/assets/images/logo.png",
foundingDate: "2002-03-08",
description:
"IT and telecom services for Japan's international community with bilingual English/Japanese support.",
address: {
"@type": "PostalAddress",
streetAddress: "3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu",
addressLocality: "Minato-ku",
addressRegion: "Tokyo",
postalCode: "106-0044",
addressCountry: "JP",
},
contactPoint: [
{
"@type": "ContactPoint",
telephone: "+81-3-3560-1006",
contactType: "customer service",
availableLanguage: ["English", "Japanese"],
},
{
"@type": "ContactPoint",
telephone: "0120-660-470",
contactType: "customer service",
areaServed: "JP",
availableLanguage: ["English", "Japanese"],
},
],
sameAs: ["https://www.asolutions.co.jp"],
};
// Disable static generation for the entire app since it uses dynamic features extensively
@ -34,7 +81,13 @@ export default async function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${GeistSans.variable} ${sora.variable} antialiased`}>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
/>
</head>
<body className="antialiased">
<QueryProvider nonce={nonce}>
{children}
<SessionTimeoutWarning />

View File

@ -0,0 +1,22 @@
import type { MetadataRoute } from "next";
/**
* Robots.txt configuration
*
* Controls search engine crawler access.
* Allows all public pages, blocks account/authenticated areas.
*/
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp";
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/account/", "/api/", "/auth/", "/_next/", "/order/"],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

View File

@ -0,0 +1,33 @@
import type { MetadataRoute } from "next";
/**
* Sitemap for SEO
*
* Generates a sitemap.xml for search engine crawlers.
* Only includes public pages that should be indexed.
*/
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://portal.asolutions.co.jp";
// Public pages that should be indexed
const publicPages = [
{ path: "", priority: 1.0, changeFrequency: "weekly" as const },
{ path: "/about", priority: 0.8, changeFrequency: "monthly" as const },
{ path: "/services", priority: 0.9, changeFrequency: "weekly" as const },
{ path: "/services/internet", priority: 0.8, changeFrequency: "weekly" as const },
{ path: "/services/sim", priority: 0.8, changeFrequency: "weekly" as const },
{ path: "/services/vpn", priority: 0.7, changeFrequency: "monthly" as const },
{ path: "/services/tv", priority: 0.7, changeFrequency: "monthly" as const },
{ path: "/services/onsite", priority: 0.7, changeFrequency: "monthly" as const },
{ path: "/services/business", priority: 0.7, changeFrequency: "monthly" as const },
{ path: "/contact", priority: 0.8, changeFrequency: "monthly" as const },
{ path: "/help", priority: 0.6, changeFrequency: "monthly" as const },
];
return publicPages.map(page => ({
url: `${baseUrl}${page.path}`,
lastModified: new Date(),
changeFrequency: page.changeFrequency,
priority: page.priority,
}));
}

View File

@ -94,15 +94,15 @@ export function SiteFooter() {
href="/contact"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Contact
Support & Contact
</Link>
</li>
<li>
<Link
href="/help"
href="/blog"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Support
Blog
</Link>
</li>
</ul>

View File

@ -7,11 +7,23 @@
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Logo } from "@/components/atoms/logo";
import { SiteFooter } from "@/components/organisms/SiteFooter";
import { useAuthStore } from "@/features/auth/stores/auth.store";
import {
Wifi,
Smartphone,
Building2,
Lock,
Wrench,
ChevronDown,
ArrowRight,
Menu,
X,
Globe,
} from "lucide-react";
export interface PublicShellProps {
children: ReactNode;
@ -21,6 +33,10 @@ export function PublicShell({ children }: PublicShellProps) {
const isAuthenticated = useAuthStore(state => state.isAuthenticated);
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
const checkAuth = useAuthStore(state => state.checkAuth);
const [servicesOpen, setServicesOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const servicesDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!hasCheckedAuth) {
@ -28,78 +44,348 @@ export function PublicShell({ children }: PublicShellProps) {
}
}, [checkAuth, hasCheckedAuth]);
// Detect touch device
useEffect(() => {
setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
}, []);
// Close dropdown when clicking outside
useEffect(() => {
if (!servicesOpen) return;
function handleClickOutside(event: MouseEvent) {
if (
servicesDropdownRef.current &&
!servicesDropdownRef.current.contains(event.target as Node)
) {
setServicesOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [servicesOpen]);
// Close mobile menu when route changes or on escape key
useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setMobileMenuOpen(false);
setServicesOpen(false);
}
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, []);
// Prevent body scroll when mobile menu is open
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [mobileMenuOpen]);
const handleServicesClick = useCallback(
(e: React.MouseEvent) => {
if (isTouchDevice) {
e.preventDefault();
setServicesOpen(prev => !prev);
}
},
[isTouchDevice]
);
const closeMobileMenu = useCallback(() => {
setMobileMenuOpen(false);
setServicesOpen(false);
}, []);
const serviceItems = [
{
href: "/services/internet",
label: "Internet Plans",
desc: "NTT Fiber up to 10Gbps",
icon: <Wifi className="h-5 w-5" />,
color: "bg-sky-50 text-sky-600",
},
{
href: "/services/sim",
label: "Phone Plans",
desc: "Docomo network SIM cards",
icon: <Smartphone className="h-5 w-5" />,
color: "bg-emerald-50 text-emerald-600",
},
{
href: "/services/business",
label: "Business Solutions",
desc: "Enterprise IT services",
icon: <Building2 className="h-5 w-5" />,
color: "bg-violet-50 text-violet-600",
},
{
href: "/services/vpn",
label: "VPN Service",
desc: "US & UK server access",
icon: <Lock className="h-5 w-5" />,
color: "bg-amber-50 text-amber-600",
},
{
href: "/services/onsite",
label: "Onsite Support",
desc: "Tech help at your location",
icon: <Wrench className="h-5 w-5" />,
color: "bg-slate-100 text-slate-600",
},
];
return (
<div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Skip to main content link for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md"
>
Skip to main content
</a>
{/* Subtle background pattern - clean and minimal */}
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" />
<header className="sticky top-0 z-40 border-b border-border/40 bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/80">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] h-16 flex items-center justify-between gap-4">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] h-16 grid grid-cols-[auto_1fr_auto] items-center gap-4">
<Link href="/" className="inline-flex items-center gap-2.5 min-w-0 group">
<span className="inline-flex items-center justify-center h-9 w-9 rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary/15">
<Logo size={20} />
</span>
<span className="min-w-0 hidden sm:block">
<span className="block text-base font-bold leading-none tracking-tight text-foreground">
Assist Solutions
Assist Solution
</span>
</span>
</Link>
<nav className="flex items-center gap-1 sm:gap-2">
<Link
href="/services"
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center justify-center gap-1 sm:gap-3 text-sm font-semibold text-muted-foreground">
<div
ref={servicesDropdownRef}
className="relative"
onMouseEnter={() => !isTouchDevice && setServicesOpen(true)}
onMouseLeave={() => !isTouchDevice && setServicesOpen(false)}
>
Services
</Link>
<Link
href="/services"
onClick={handleServicesClick}
className="inline-flex items-center gap-1 px-3 py-2 rounded-md hover:text-foreground transition-colors"
aria-expanded={servicesOpen}
aria-haspopup="true"
>
Services
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${servicesOpen ? "rotate-180" : ""}`}
/>
</Link>
{servicesOpen && (
<div className="absolute left-1/2 -translate-x-1/2 top-full pt-2 z-50">
{/* Arrow pointer */}
<div className="absolute left-1/2 -translate-x-1/2 -top-0 w-3 h-3 rotate-45 bg-white border-l border-t border-border/50" />
<div className="w-[420px] rounded-2xl border border-border/50 bg-white shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200">
{/* Header */}
<div className="px-5 py-3 bg-gradient-to-r from-primary/5 to-transparent border-b border-border/30">
<p className="text-xs font-semibold text-primary uppercase tracking-wider">
Browse Our Services
</p>
</div>
{/* Services Grid */}
<div className="p-3 grid grid-cols-2 gap-1">
{serviceItems.map(item => (
<Link
key={item.href}
href={item.href}
onClick={() => setServicesOpen(false)}
className="group flex items-start gap-3 rounded-xl p-3 hover:bg-muted/50 transition-all duration-150"
>
<div
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg ${item.color} transition-transform group-hover:scale-110`}
>
{item.icon}
</div>
<div className="min-w-0 pt-0.5">
<p className="text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
{item.label}
</p>
<p className="text-xs text-muted-foreground leading-snug">
{item.desc}
</p>
</div>
</Link>
))}
</div>
{/* Footer CTA */}
<div className="px-5 py-3 bg-muted/30 border-t border-border/30">
<Link
href="/services"
onClick={() => setServicesOpen(false)}
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary hover:text-primary/80 transition-colors"
>
View all services
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</div>
)}
</div>
<Link
href="/about"
className="hidden sm:inline-flex items-center rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
className="inline-flex items-center px-3 py-2 rounded-md hover:text-foreground transition-colors"
>
About
</Link>
<Link
href="/contact"
className="hidden sm:inline-flex items-center rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
href="/blog"
className="inline-flex items-center px-3 py-2 rounded-md hover:text-foreground transition-colors"
>
Contact
Blog
</Link>
<Link
href="/help"
className="hidden md:inline-flex items-center rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
href="/contact"
className="inline-flex items-center px-3 py-2 rounded-md hover:text-foreground transition-colors"
>
Support
</Link>
</nav>
{/* Mobile: Language indicator + hamburger */}
<div className="flex md:hidden items-center justify-center">
<div className="flex items-center gap-1 text-sm text-muted-foreground mr-2">
<Globe className="h-4 w-4" />
<span className="font-medium">EN</span>
</div>
</div>
<div className="flex items-center gap-3 justify-self-end">
{/* Language Selector - Desktop */}
<div className="hidden md:flex items-center gap-1 text-sm text-muted-foreground">
<Globe className="h-4 w-4" />
<span className="font-medium">EN</span>
</div>
{/* Auth Button - Desktop */}
{isAuthenticated ? (
<Link
href="/account"
className="ml-2 inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="hidden md:inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
My Account
</Link>
) : (
<div className="flex items-center gap-2 ml-2">
<Link
href="/auth/login"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
Sign in
</Link>
<Link
href="/auth/get-started"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
Get Started
</Link>
</div>
<Link
href="/auth/login"
className="hidden md:inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
Sign in
</Link>
)}
</nav>
{/* Mobile Menu Button */}
<button
type="button"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-md hover:bg-muted/50 transition-colors"
aria-expanded={mobileMenuOpen}
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
>
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
</div>
</header>
<main className="flex-1">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-12 sm:py-16">
{/* Mobile Menu Overlay - Rendered outside header to avoid stacking context issues */}
{mobileMenuOpen && (
<div className="md:hidden fixed inset-0 top-16 z-50 bg-white animate-in fade-in duration-200 overflow-y-auto">
<nav className="flex flex-col p-6 space-y-2">
<div className="space-y-1">
<p className="text-xs font-semibold text-primary uppercase tracking-wider px-3 py-2">
Services
</p>
{serviceItems.map(item => (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className="flex items-center gap-3 px-3 py-3 rounded-lg hover:bg-muted/50 transition-colors"
>
<div
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg ${item.color}`}
>
{item.icon}
</div>
<div>
<p className="text-sm font-semibold text-foreground">{item.label}</p>
<p className="text-xs text-muted-foreground">{item.desc}</p>
</div>
</Link>
))}
</div>
<div className="border-t border-border/40 pt-4 space-y-1">
<Link
href="/about"
onClick={closeMobileMenu}
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors"
>
About
</Link>
<Link
href="/blog"
onClick={closeMobileMenu}
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors"
>
Blog
</Link>
<Link
href="/contact"
onClick={closeMobileMenu}
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors"
>
Support
</Link>
</div>
<div className="border-t border-border/40 pt-4">
{isAuthenticated ? (
<Link
href="/account"
onClick={closeMobileMenu}
className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors"
>
My Account
</Link>
) : (
<Link
href="/auth/login"
onClick={closeMobileMenu}
className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors"
>
Sign in
</Link>
)}
</div>
</nav>
</div>
)}
<main id="main-content" className="flex-1">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] pt-0 pb-0">
{children}
</div>
</main>

View File

@ -2,45 +2,108 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
export function PublicLandingLoadingView() {
return (
<div className="space-y-20 pb-8 pt-8 sm:pt-16">
<div className="space-y-0 pb-8 pt-0 sm:pt-0">
{/* Hero Section Skeleton */}
<section className="text-center space-y-8 max-w-4xl mx-auto px-4">
<div className="flex flex-col items-center space-y-6">
<Skeleton className="h-8 w-64 rounded-full" />
<div className="space-y-3 w-full flex flex-col items-center">
<Skeleton className="h-16 w-3/4 sm:w-1/2" />
<Skeleton className="h-16 w-2/3 sm:w-1/3" />
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#f7f7f7] py-12 sm:py-16">
<div className="mx-auto max-w-6xl grid grid-cols-1 lg:grid-cols-[1.05fr_minmax(0,0.95fr)] items-center gap-10 lg:gap-16 px-6 sm:px-10 lg:px-14 pt-0">
<div className="space-y-4 max-w-2xl">
<div className="space-y-2">
<Skeleton className="h-10 w-72 max-w-full rounded-md" />
<Skeleton className="h-10 w-80 max-w-full rounded-md" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full rounded-md" />
<Skeleton className="h-4 w-4/5 rounded-md" />
</div>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Skeleton className="h-12 w-48 rounded-full" />
<Skeleton className="h-12 w-48 rounded-full" />
</div>
</div>
<div className="space-y-2 w-full flex flex-col items-center">
<Skeleton className="h-6 w-3/4 sm:w-2/3" />
<Skeleton className="h-6 w-1/2 sm:w-1/3" />
<div className="w-full">
<Skeleton className="w-full aspect-[4/3] rounded-2xl" />
</div>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4">
<Skeleton className="h-14 w-48 rounded-lg" />
<Skeleton className="h-14 w-40 rounded-lg" />
</div>
</section>
{/* Concept Section Skeleton */}
<section className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12 flex flex-col items-center space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-64" />
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex flex-col items-center text-center space-y-4">
<Skeleton className="h-14 w-14 rounded-xl" />
<Skeleton className="h-8 w-48" />
<div className="space-y-2 w-full flex flex-col items-center">
<Skeleton className="h-4 w-64 max-w-full" />
<Skeleton className="h-4 w-48 max-w-full" />
{/* Solutions Carousel Skeleton */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#8dc3fb] py-12 sm:py-14">
<div className="mx-auto max-w-7xl px-6 sm:px-10 lg:px-14 space-y-6">
<Skeleton className="h-10 w-40 rounded-md" />
<div className="flex gap-6 overflow-hidden">
{Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="w-[260px] flex-shrink-0">
<Skeleton className="h-64 w-full rounded-3xl" />
</div>
))}
</div>
</div>
</section>
{/* Trust and Excellence Skeleton */}
<section className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 py-14 sm:py-16">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.1fr] gap-10 lg:gap-14 items-center">
<Skeleton className="h-full w-full rounded-2xl min-h-[320px]" />
<div className="space-y-4">
<Skeleton className="h-4 w-36 rounded-md" />
<Skeleton className="h-10 w-3/4 rounded-md" />
<Skeleton className="h-10 w-1/2 rounded-md" />
<div className="space-y-3 pt-2">
{Array.from({ length: 3 }).map((_, idx) => (
<div key={idx} className="flex items-center gap-3">
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-5 w-40 rounded-md" />
</div>
))}
</div>
<Skeleton className="h-5 w-40 rounded-md" />
</div>
</div>
</section>
{/* Support Downloads Skeleton */}
<section className="max-w-5xl mx-auto px-6 sm:px-10 lg:px-14 pb-16">
<Skeleton className="h-10 w-40 mx-auto rounded-md mb-10" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8 sm:gap-10">
{Array.from({ length: 2 }).map((_, idx) => (
<div
key={idx}
className="rounded-2xl border border-border/60 bg-white p-6 shadow-sm space-y-4"
>
<Skeleton className="h-5 w-32 mx-auto rounded-md" />
<Skeleton className="h-24 w-24 mx-auto rounded-full" />
<Skeleton className="h-9 w-32 mx-auto rounded-md" />
</div>
))}
</div>
</section>
{/* Contact Skeleton */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#e8f5ff] py-14 sm:py-16">
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 space-y-6">
<Skeleton className="h-10 w-48 rounded-md" />
<div className="grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-10 lg:gap-12">
<div className="rounded-2xl bg-white shadow-sm border border-border/60 p-6 sm:p-8 space-y-4">
<Skeleton className="h-8 w-48 rounded-md" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Skeleton className="h-11 w-full rounded-md" />
<Skeleton className="h-11 w-full rounded-md" />
</div>
<Skeleton className="h-11 w-full rounded-md" />
<Skeleton className="h-11 w-full rounded-md" />
<Skeleton className="h-11 w-full rounded-md" />
<Skeleton className="h-11 w-full rounded-md" />
<Skeleton className="h-28 w-full rounded-md" />
<Skeleton className="h-11 w-full rounded-md" />
</div>
<div className="space-y-6">
<Skeleton className="h-48 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-20 w-full rounded-2xl" />
</div>
</div>
</div>
</section>
</div>
);
}

View File

@ -1,14 +1,19 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Wifi,
Smartphone,
Building2,
Users,
Calendar,
CircleDollarSign,
Phone,
MapPin,
Clock,
CheckCircle,
Lock,
Wrench,
Heart,
Clock3,
Lightbulb,
Globe,
Shield,
Quote,
} from "lucide-react";
/**
@ -18,209 +23,420 @@ import {
* and mission statement for Assist Solutions.
*/
export function AboutUsView() {
return (
<div className="max-w-4xl mx-auto space-y-12">
{/* Header */}
<div className="text-center">
<h1
className="text-display-lg font-display font-bold text-foreground mb-4 animate-in fade-in slide-in-from-bottom-6 duration-700"
style={{ animationDelay: "0ms" }}
>
About Us
</h1>
<p
className="text-lg text-muted-foreground max-w-2xl mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "100ms" }}
>
We specialize in serving Japan&apos;s international community with the most reliable and
cost-efficient IT solutions available.
</p>
</div>
// Sample company logos for the trusted by carousel
const trustedCompanies = [
{ name: "Company 1", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 2", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 3", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 4", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 5", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 6", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 7", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 8", logo: "/assets/images/placeholder-logo.png" },
];
{/* Who We Are Section */}
<section
className="bg-card rounded-2xl border border-border p-8 sm:p-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "200ms" }}
>
<div className="flex items-center gap-3 mb-6">
<div className="h-12 w-12 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center">
<Building2 className="h-6 w-6 text-primary" />
const values = [
{
text: "Make technology accessible for everyone, regardless of language barriers.",
icon: <Heart className="h-6 w-6" />,
color: "bg-rose-50 text-rose-500 border-rose-100",
},
{
text: "Save our customers time by handling Japanese bureaucracy and paperwork for them.",
icon: <Clock3 className="h-6 w-6" />,
color: "bg-amber-50 text-amber-500 border-amber-100",
},
{
text: "Stay current with the latest technology to provide the best solutions for our clients.",
icon: <Lightbulb className="h-6 w-6" />,
color: "bg-sky-50 text-sky-500 border-sky-100",
},
{
text: "Be a bridge between Japan's tech infrastructure and its international community.",
icon: <Globe className="h-6 w-6" />,
color: "bg-emerald-50 text-emerald-500 border-emerald-100",
},
{
text: "Operate with transparency and integrity in all our customer relationships.",
icon: <Shield className="h-6 w-6" />,
color: "bg-violet-50 text-violet-500 border-violet-100",
},
];
const services = [
{
title: "Internet Plans",
description:
"High-speed NTT fiber with English support. We handle the Japanese paperwork so you don't have to.",
icon: <Wifi className="h-7 w-7 text-primary" />,
},
{
title: "Phone Plans",
description:
"SIM cards on Japan's best network. Foreign credit cards accepted, no hanko required.",
icon: <Smartphone className="h-7 w-7 text-primary" />,
},
{
title: "Business Solutions",
description:
"Enterprise IT for international companies. Dedicated internet, office networks, and data centers.",
icon: <Building2 className="h-7 w-7 text-primary" />,
},
{
title: "VPN",
description: "Stream your favorite shows from home. Pre-configured router for US/UK content.",
icon: <Lock className="h-7 w-7 text-primary" />,
},
{
title: "Onsite Support",
description: "English-speaking technicians at your door for setup and troubleshooting.",
icon: <Wrench className="h-7 w-7 text-primary" />,
},
];
const carouselRef = useRef<HTMLDivElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const computeScrollAmount = useCallback(() => {
const container = carouselRef.current;
if (!container) return;
const card = container.querySelector<HTMLElement>("[data-business-card]");
if (!card) return;
const gap =
Number.parseFloat(getComputedStyle(container).columnGap || "0") ||
Number.parseFloat(getComputedStyle(container).gap || "0") ||
24;
setScrollAmount(card.clientWidth + gap);
}, []);
const scrollServices = useCallback(
(direction: 1 | -1) => {
const container = carouselRef.current;
if (!container) return;
const amount = scrollAmount || container.clientWidth;
container.scrollBy({ left: direction * amount, behavior: "smooth" });
},
[scrollAmount]
);
useEffect(() => {
computeScrollAmount();
window.addEventListener("resize", computeScrollAmount);
return () => window.removeEventListener("resize", computeScrollAmount);
}, [computeScrollAmount]);
return (
<div className="space-y-0">
{/* Hero with geometric pattern */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-gradient-to-br from-slate-50 to-sky-50/30 py-12 sm:py-16 overflow-hidden">
{/* Dot grid pattern */}
<div
className="absolute inset-0 opacity-[0.4]"
style={{
backgroundImage: `radial-gradient(circle, #0ea5e9 1px, transparent 1px)`,
backgroundSize: "24px 24px",
}}
/>
{/* Subtle gradient overlay for depth */}
<div className="absolute inset-0 bg-gradient-to-t from-white/80 via-transparent to-white/40" />
<div className="relative max-w-6xl mx-auto px-6 sm:px-8">
<div className="grid grid-cols-1 lg:grid-cols-[1.05fr_minmax(0,0.95fr)] gap-10 items-center">
<div className="space-y-5">
<h1 className="text-4xl sm:text-5xl font-extrabold text-primary leading-tight tracking-tight">
About Us
</h1>
<div className="space-y-4 text-muted-foreground leading-relaxed text-base sm:text-lg">
<p>
Since 2002, Assist Solutions has been the trusted IT partner for expats and
international businesses in Japan. We understand the unique challenges of living
and working in a country where language barriers can make simple tasks difficult.
</p>
<p>
Our bilingual team provides internet, mobile, VPN, and tech support services with
full English support. No Japanese required. We handle everything from contracts to
installation coordination, so you can focus on enjoying life in Japan.
</p>
</div>
</div>
<div className="relative h-full min-h-[420px]">
<Image
src="/assets/images/About us.png"
alt="Assist Solutions team in Tokyo"
fill
priority
className="object-contain drop-shadow-lg"
sizes="(max-width: 1024px) 100vw, 45vw"
/>
</div>
</div>
<h2 className="text-display-sm font-display font-bold text-foreground">Who We Are</h2>
</div>
<div className="space-y-4 text-muted-foreground leading-relaxed">
<p>
Assist Solutions Corp. is a privately-owned entrepreneurial IT service company. We
specialize in serving Japan&apos;s international community with the most reliable and
cost-efficient IT & TV solutions available.
</p>
<p>
We are dedicated to providing comfortable support for our customer&apos;s diverse needs
in both English and Japanese. We believe that our excellent bi-lingual support and
flexible service along with our knowledge and experience in the field are what sets us
apart from the rest of the information technology and broadcasting industry.
</p>
</section>
{/* Trusted By Section - Infinite Carousel */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white py-10 sm:py-12 overflow-hidden">
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-[#e8f5ff] pointer-events-none z-10" />
<div className="max-w-6xl mx-auto px-6 sm:px-8 mb-8">
<h2 className="text-center text-lg sm:text-xl font-semibold text-muted-foreground">
Trusted by Leading Companies
</h2>
</div>
{/* Infinite Carousel */}
<div className="relative">
{/* Gradient masks for fade effect on edges */}
<div className="absolute left-0 top-0 bottom-0 w-24 sm:w-32 bg-gradient-to-r from-white to-transparent z-10 pointer-events-none" />
<div className="absolute right-0 top-0 bottom-0 w-24 sm:w-32 bg-gradient-to-l from-white to-transparent z-10 pointer-events-none" />
{/* Scrolling container */}
<div className="flex overflow-hidden">
<div className="flex animate-scroll-infinite gap-12 sm:gap-16">
{/* First set of logos */}
{trustedCompanies.map((company, index) => (
<div
key={`logo-1-${index}`}
className="flex-shrink-0 w-28 h-16 sm:w-36 sm:h-20 flex items-center justify-center grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
>
<Image
src={company.logo}
alt={company.name}
width={120}
height={60}
className="object-contain max-h-full"
/>
</div>
))}
{/* Duplicate set for seamless loop */}
{trustedCompanies.map((company, index) => (
<div
key={`logo-2-${index}`}
className="flex-shrink-0 w-28 h-16 sm:w-36 sm:h-20 flex items-center justify-center grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
>
<Image
src={company.logo}
alt={company.name}
width={120}
height={60}
className="object-contain max-h-full"
/>
</div>
))}
</div>
</div>
</div>
{/* CSS for infinite scroll animation */}
<style jsx>{`
@keyframes scroll-infinite {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-scroll-infinite {
animation: scroll-infinite 30s linear infinite;
}
.animate-scroll-infinite:hover {
animation-play-state: paused;
}
`}</style>
</section>
{/* Business Solutions Carousel */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#e8f5ff] py-10">
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-white pointer-events-none" />
<div className="mx-auto max-w-6xl px-6 sm:px-10">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl sm:text-3xl font-bold text-foreground">Business</h2>
</div>
<div className="relative">
<div
ref={carouselRef}
className="flex gap-6 overflow-x-auto scroll-smooth pb-6 pr-20"
style={{ scrollbarWidth: "none" }}
>
{services.map((service, idx) => (
<article
key={`${service.title}-${idx}`}
data-business-card
className="flex-shrink-0 w-[240px] rounded-3xl bg-white px-6 py-7 shadow-md border border-white/60"
>
<div className="mb-4">{service.icon}</div>
<h3 className="text-lg font-semibold text-foreground mb-2">{service.title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{service.description}
</p>
</article>
))}
</div>
<div className="absolute bottom-0 right-2 flex gap-3">
<button
type="button"
onClick={() => scrollServices(-1)}
className="h-9 w-9 rounded-full border border-black/15 bg-white text-foreground shadow-sm hover:bg-white/90"
aria-label="Scroll business left"
>
</button>
<button
type="button"
onClick={() => scrollServices(1)}
className="h-9 w-9 rounded-full border border-black/15 bg-white text-foreground shadow-sm hover:bg-white/90"
aria-label="Scroll business right"
>
</button>
</div>
</div>
</div>
</section>
{/* Our Values Section */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white py-12 sm:py-14">
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-[#f7f7f7] pointer-events-none" />
<div className="max-w-6xl mx-auto px-6 sm:px-8 space-y-8">
<div className="text-center max-w-2xl mx-auto">
<h2 className="text-2xl sm:text-3xl font-bold text-primary mb-3">Our Values</h2>
<p className="text-muted-foreground leading-relaxed">
These principles guide how we serve customers, support our community, and advance our
craft every day.
</p>
</div>
{/* Values Grid - 3 on top, 2 centered on bottom */}
<div className="space-y-4">
{/* Top row - 3 cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{values.slice(0, 3).map((value, index) => (
<div
key={index}
className="group relative bg-white rounded-2xl border border-border/60 p-6 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300"
>
{/* Icon */}
<div
className={`inline-flex items-center justify-center w-12 h-12 rounded-xl border mb-4 ${value.color}`}
>
{value.icon}
</div>
{/* Quote mark decoration */}
<Quote className="absolute top-4 right-4 h-8 w-8 text-muted-foreground/10 rotate-180" />
{/* Text */}
<p className="text-foreground font-medium leading-relaxed">{value.text}</p>
</div>
))}
</div>
{/* Bottom row - 2 cards centered */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl mx-auto lg:max-w-none lg:grid-cols-2 lg:px-[16.666%]">
{values.slice(3).map((value, index) => (
<div
key={index + 3}
className="group relative bg-white rounded-2xl border border-border/60 p-6 shadow-sm hover:shadow-md hover:-translate-y-1 transition-all duration-300"
>
{/* Icon */}
<div
className={`inline-flex items-center justify-center w-12 h-12 rounded-xl border mb-4 ${value.color}`}
>
{value.icon}
</div>
{/* Quote mark decoration */}
<Quote className="absolute top-4 right-4 h-8 w-8 text-muted-foreground/10 rotate-180" />
{/* Text */}
<p className="text-foreground font-medium leading-relaxed">{value.text}</p>
</div>
))}
</div>
</div>
</div>
</section>
{/* Corporate Data Section */}
<section
className="bg-card rounded-2xl border border-border p-8 sm:p-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<div className="flex items-center gap-3 mb-6">
<div className="h-12 w-12 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center">
<Users className="h-6 w-6 text-primary" />
</div>
<h2 className="text-display-sm font-display font-bold text-foreground">Corporate Data</h2>
</div>
<p className="text-muted-foreground mb-6">
Assist Solutions is a privately-owned entrepreneurial IT supporting company, focused on
the international community in Japan.
</p>
<div className="divide-y divide-border">
{/* Company Name */}
<div className="py-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="font-medium text-foreground">Name</div>
<div className="sm:col-span-2 text-muted-foreground">
Assist Solutions Corp.
<br />
<span className="text-sm">(Notified Telecommunication Carrier: A-19-9538)</span>
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#f7f7f7] py-12 pb-16">
<div className="max-w-6xl mx-auto px-6 sm:px-8 space-y-3">
{/* Row 1: headings same level */}
<div className="grid grid-cols-1 lg:grid-cols-[1.2fr_0.8fr] gap-x-10 gap-y-2 items-start">
<h2 className="text-2xl font-bold text-foreground">Corporate Data</h2>
<div>
<h3 className="text-xl font-bold text-foreground mb-2">Address</h3>
<p className="text-muted-foreground font-semibold leading-relaxed">
3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu,
<br />
Minato-ku, Tokyo 106-0044
<br />
Tel: 03-3560-1006 Fax: 03-3560-1007
</p>
</div>
</div>
{/* Address */}
<div className="py-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="font-medium text-foreground flex items-center gap-2">
<MapPin className="h-4 w-4 text-primary" />
Address
</div>
<div className="sm:col-span-2 text-muted-foreground">
3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu,
<br />
Minato-ku, Tokyo 106-0044
</div>
</div>
{/* Row 2: corporate data list | map (no stretch, no extra space below left column) */}
<div className="grid grid-cols-1 lg:grid-cols-[1.2fr_0.8fr] gap-x-10 gap-y-2 items-start">
<div className="space-y-2">
<div>
<h3 className="text-xl font-semibold text-foreground flex items-center gap-2">
<span className="h-6 w-1 rounded-full bg-primary" />
Representative Director
</h3>
<p className="text-muted-foreground font-semibold mt-0.5">Daisuke Nagakawa</p>
</div>
{/* Phone/Fax */}
<div className="py-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="font-medium text-foreground flex items-center gap-2">
<Phone className="h-4 w-4 text-primary" />
Tel / Fax
</div>
<div className="sm:col-span-2 text-muted-foreground">
Tel: 03-3560-1006
<br />
Fax: 03-3560-1007
</div>
</div>
<div>
<h3 className="text-xl font-semibold text-foreground flex items-center gap-2">
<span className="h-6 w-1 rounded-full bg-primary" />
Employees
</h3>
<p className="text-muted-foreground font-semibold mt-0.5">
21 Staff Members (as of March 31st, 2025)
</p>
</div>
{/* Business Hours */}
<div className="py-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="font-medium text-foreground flex items-center gap-2">
<Clock className="h-4 w-4 text-primary" />
Business Hours
</div>
<div className="sm:col-span-2 text-muted-foreground space-y-1">
<div>Mon - Fri 9:30AM - 6:00PM Customer Support Team</div>
<div>Mon - Fri 9:30AM - 6:00PM In-office Tech Support Team</div>
<div>Mon - Sat 10:00AM - 9:00PM Onsite Tech Support Team</div>
</div>
</div>
<div>
<h3 className="text-xl font-semibold text-foreground flex items-center gap-2">
<span className="h-6 w-1 rounded-full bg-primary" />
Established
</h3>
<p className="text-muted-foreground font-semibold mt-0.5">March 8, 2002</p>
</div>
{/* Representative */}
<div className="py-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="font-medium text-foreground">Representative Director</div>
<div className="sm:col-span-2 text-muted-foreground">Daisuke Nagakawa</div>
</div>
<div>
<h3 className="text-xl font-semibold text-foreground flex items-center gap-2">
<span className="h-6 w-1 rounded-full bg-primary" />
Paid in Capital
</h3>
<p className="text-muted-foreground font-semibold mt-0.5">40,000,000 JPY</p>
</div>
{/* Employees */}
<div className="py-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="font-medium text-foreground">Employees</div>
<div className="sm:col-span-2 text-muted-foreground">
21 Staff Members (as of March 31st, 2025)
<div>
<h3 className="text-xl font-semibold text-foreground flex items-center gap-2">
<span className="h-6 w-1 rounded-full bg-primary" />
Business Hours
</h3>
<div className="text-muted-foreground font-semibold mt-0.5 space-y-0.5">
<p>Mon - Fri 9:30AM - 6:00PM Customer Support Team</p>
<p>Mon - Fri 9:30AM - 6:00PM In-office Tech Support Team</p>
<p>Mon - Sat 10:00AM - 9:00PM Onsite Tech Support Team</p>
</div>
</div>
</div>
</div>
{/* Established */}
<div className="py-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="font-medium text-foreground flex items-center gap-2">
<Calendar className="h-4 w-4 text-primary" />
Established
<div className="rounded-2xl overflow-hidden w-full min-h-[320px]">
<iframe
title="Assist Solutions Corp Map"
src="https://www.google.com/maps?q=Assist+Solutions+Corp,+3-8-2+Higashi+Azabu,+Minato-ku,+Tokyo&output=embed"
className="w-full h-[320px] block"
loading="lazy"
allowFullScreen
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
<div className="sm:col-span-2 text-muted-foreground">March 8, 2002</div>
</div>
{/* Capital */}
<div className="py-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="font-medium text-foreground flex items-center gap-2">
<CircleDollarSign className="h-4 w-4 text-primary" />
Paid-in Capital
</div>
<div className="sm:col-span-2 text-muted-foreground">40,000,000 JPY</div>
</div>
</div>
</section>
{/* Business Activities Section */}
<section
className="bg-card rounded-2xl border border-border p-8 sm:p-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "400ms" }}
>
<h2 className="text-display-sm font-display font-bold text-foreground mb-6">
Business Activities
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
"IT Consulting Services",
"TV Consulting Services",
"Internet Connection Service Provision (SonixNet ISP)",
"VPN Connection Service Provision (SonixNet US/UK Remote Access)",
"Agent for Telecommunication Services",
"Agent for Internet Services",
"Agent for TV Services",
"Onsite Support Service for IT",
"Onsite Support Service for TV",
"Server Management Service",
"Network Management Service",
].map((activity, index) => (
<div key={index} className="flex items-start gap-2">
<CheckCircle className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground">{activity}</span>
</div>
))}
</div>
</section>
{/* Mission Statement Section */}
<section
className="bg-gradient-to-br from-primary/5 to-transparent rounded-2xl border border-primary/20 p-8 sm:p-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "500ms" }}
>
<h2 className="text-display-sm font-display font-bold text-foreground mb-6">
Mission Statement
</h2>
<p className="text-muted-foreground mb-6 leading-relaxed">
We will achieve business success by pursuing the following:
</p>
<ul className="space-y-4">
{[
"Provide the most customer-oriented service in this industry in Japan.",
"Through our service, we save client's time and enrich customers' lives.",
"We always have the latest and most efficient knowledge required for our service.",
"Be a responsible participant in Japan's international community.",
"Maintain high ethical standards in all business activities.",
].map((mission, index) => (
<li key={index} className="flex items-start gap-3">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary text-sm font-semibold flex-shrink-0">
{index + 1}
</span>
<span className="text-foreground leading-relaxed">{mission}</span>
</li>
))}
</ul>
</section>
</div>
);
}

View File

@ -1,3 +1,6 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { CheckCircle } from "lucide-react";
import { cn } from "@/shared/utils";
@ -11,79 +14,52 @@ export interface HighlightFeature {
interface ServiceHighlightsProps {
features: HighlightFeature[];
className?: string;
/** Layout variant */
variant?: "grid" | "compact";
}
function HighlightItem({ icon, title, description, highlight }: HighlightFeature) {
return (
<div
className={cn(
"flex flex-col h-full p-5 rounded-2xl",
"cp-glass-card",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:-translate-y-0.5 hover:shadow-lg"
)}
>
<div className="flex items-start justify-between gap-4 mb-3">
<div
className={cn(
"flex h-11 w-11 items-center justify-center rounded-xl flex-shrink-0",
"bg-primary/10 text-primary",
"transition-transform duration-[var(--cp-duration-normal)]",
"group-hover:scale-105"
)}
>
<div className="group relative flex flex-col h-full p-5 rounded-xl bg-muted/30 border-l-4 border-l-primary/60 border-y border-r border-border/30 hover:bg-muted/50 transition-colors duration-200">
{/* Icon - smaller, inline style */}
<div className="flex items-center gap-3 mb-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary flex-shrink-0">
{icon}
</div>
{highlight && (
<span
className={cn(
"inline-flex items-center gap-1.5 py-1 px-2.5 rounded-full",
"bg-success/10 text-success",
"text-[10px] font-bold leading-tight"
)}
>
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0" />
<span className="break-words">{highlight}</span>
<span className="inline-flex items-center gap-1 py-0.5 px-2 rounded-full bg-primary/10 text-[10px] font-semibold text-primary whitespace-nowrap">
<CheckCircle className="h-3 w-3" />
{highlight}
</span>
)}
</div>
<h3 className="font-bold text-foreground text-base mb-2">{title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p>
<h3 className="font-semibold text-foreground text-sm mb-1.5">{title}</h3>
<p className="text-xs text-muted-foreground leading-relaxed flex-grow">{description}</p>
</div>
);
}
function CompactHighlightItem({ icon, title, description, highlight }: HighlightFeature) {
/**
* Mobile Carousel Item - Compact card for horizontal scrolling
*/
function MobileCarouselItem({ icon, title, description, highlight }: HighlightFeature) {
return (
<div
className={cn(
"flex items-start gap-4 p-4 rounded-xl",
"bg-card/50 border border-border/50",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:bg-card hover:border-border"
)}
>
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0",
"bg-primary/10 text-primary"
)}
>
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-foreground text-sm">{title}</h3>
<div className="flex-shrink-0 w-[280px] snap-center">
<div className="h-full p-4 rounded-xl bg-gradient-to-br from-muted/40 to-muted/20 border border-border/40 shadow-sm">
{/* Top row: Icon + Highlight badge */}
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary flex-shrink-0">
{icon}
</div>
{highlight && (
<span className="text-[10px] font-medium text-success bg-success/10 px-2 py-0.5 rounded-full">
<span className="inline-flex items-center gap-1 py-0.5 px-2 rounded-full bg-primary/10 text-[10px] font-bold text-primary uppercase tracking-wide">
{highlight}
</span>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
{/* Content */}
<h3 className="font-semibold text-foreground text-sm mb-1">{title}</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">{description}</p>
</div>
</div>
);
@ -93,33 +69,87 @@ function CompactHighlightItem({ icon, title, description, highlight }: Highlight
* ServiceHighlights
*
* A clean, grid-based layout for displaying service features/highlights.
* Supports two variants: 'grid' for larger cards and 'compact' for inline style.
* On mobile: horizontal scrolling carousel with snap points.
* On desktop: grid layout.
*/
export function ServiceHighlights({
features,
className = "",
variant = "grid",
}: ServiceHighlightsProps) {
if (variant === "compact") {
return (
<div className={cn("grid grid-cols-1 md:grid-cols-2 gap-3", className)}>
{features.map((feature, index) => (
<CompactHighlightItem key={index} {...feature} />
))}
</div>
);
}
export function ServiceHighlights({ features, className = "" }: ServiceHighlightsProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
// Track scroll position to update active dot indicator
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const handleScroll = () => {
const scrollLeft = container.scrollLeft;
const itemWidth = 280 + 12; // card width + gap
const newIndex = Math.round(scrollLeft / itemWidth);
setActiveIndex(Math.min(newIndex, features.length - 1));
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [features.length]);
// Scroll to specific item when dot is clicked
const scrollToIndex = (index: number) => {
const container = scrollContainerRef.current;
if (!container) return;
const itemWidth = 280 + 12; // card width + gap
container.scrollTo({
left: index * itemWidth,
behavior: "smooth",
});
};
return (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 cp-stagger-children",
className
)}
>
{features.map((feature, index) => (
<HighlightItem key={index} {...feature} />
))}
</div>
<>
{/* Mobile: Horizontal scrolling carousel */}
<div className={cn("md:hidden", className)}>
{/* Scroll container */}
<div
ref={scrollContainerRef}
className="flex gap-3 overflow-x-auto pb-4 -mx-4 px-4 snap-x snap-mandatory scrollbar-hide touch-pan-x"
>
{features.map((feature, index) => (
<MobileCarouselItem key={index} {...feature} />
))}
{/* End spacer for last item visibility */}
<div className="flex-shrink-0 w-1" aria-hidden="true" />
</div>
{/* Dot indicators */}
<div className="flex justify-center gap-1.5 mt-2">
{features.map((_, index) => (
<button
key={index}
type="button"
onClick={() => scrollToIndex(index)}
aria-label={`Go to slide ${index + 1}`}
className={cn(
"h-1.5 rounded-full transition-all duration-300",
activeIndex === index
? "w-6 bg-primary"
: "w-1.5 bg-muted-foreground/30 hover:bg-muted-foreground/50"
)}
/>
))}
</div>
{/* Swipe hint - only show initially */}
<p className="text-[10px] text-muted-foreground/60 text-center mt-2">
Swipe to explore features
</p>
</div>
{/* Desktop: Grid layout */}
<div className={cn("hidden md:grid md:grid-cols-2 lg:grid-cols-3 gap-5", className)}>
{features.map((feature, index) => (
<HighlightItem key={index} {...feature} />
))}
</div>
</>
);
}

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, Home, Building2, Info, X, Check, Star } 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";
@ -9,9 +9,11 @@ import { cn } from "@/shared/utils";
interface TierInfo {
tier: "Silver" | "Gold" | "Platinum";
monthlyPrice: number;
/** Max price for showing price range (when prices vary by offering type) */
maxMonthlyPrice?: number;
description: string;
features: string[];
pricingNote?: string | undefined;
pricingNote?: string;
}
interface PublicOfferingCardProps {
@ -21,35 +23,31 @@ interface PublicOfferingCardProps {
description: string;
iconType: "home" | "apartment";
startingPrice: number;
/** Maximum price for showing price range */
maxPrice?: number;
setupFee: number;
tiers: TierInfo[];
isPremium?: boolean | undefined;
isPremium?: boolean;
ctaPath: string;
defaultExpanded?: boolean | undefined;
defaultExpanded?: boolean;
/** Show info tooltip explaining connection types (for Apartment) */
showConnectionInfo?: boolean | undefined;
customCtaLabel?: string | undefined;
onCtaClick?: ((e: React.MouseEvent) => void) | undefined;
showConnectionInfo?: boolean;
customCtaLabel?: string;
onCtaClick?: (e: React.MouseEvent) => void;
}
const tierStyles = {
Silver: {
card: "border-border bg-card",
accent: "text-muted-foreground",
badge: "bg-muted text-muted-foreground",
iconBg: "bg-muted",
card: "border-gray-200 bg-white border-l-4 border-l-gray-400",
accent: "text-gray-600",
},
Gold: {
card: "border-warning/40 bg-gradient-to-br from-warning/5 to-card ring-1 ring-warning/20",
accent: "text-warning",
badge: "bg-warning/10 text-warning",
iconBg: "bg-warning/10",
card: "border-gray-200 bg-white border-l-4 border-l-amber-500",
accent: "text-amber-600",
},
Platinum: {
card: "border-primary/40 bg-gradient-to-br from-primary/5 to-card ring-1 ring-primary/20",
card: "border-gray-200 bg-white border-l-4 border-l-primary",
accent: "text-primary",
badge: "bg-primary/10 text-primary",
iconBg: "bg-primary/10",
},
} as const;
@ -58,12 +56,10 @@ const tierStyles = {
*/
function ConnectionTypeInfo({ onClose }: { onClose: () => void }) {
return (
<div className="bg-info/5 border border-info/20 rounded-xl p-4 mb-4">
<div className="bg-info-soft/50 border border-info/20 rounded-lg p-4 mb-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-info/10 flex items-center justify-center">
<Info className="h-4 w-4 text-info" />
</div>
<Info className="h-5 w-5 text-info flex-shrink-0" />
<h4 className="font-semibold text-sm text-foreground">
Why does speed vary by building?
</h4>
@ -71,12 +67,12 @@ function ConnectionTypeInfo({ onClose }: { onClose: () => void }) {
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors p-1"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-3 text-xs text-muted-foreground pl-10">
<div className="space-y-3 text-xs text-muted-foreground">
<p>
Apartment buildings in Japan have different fiber infrastructure installed by NTT. Your
available speed depends on what your building supports:
@ -102,73 +98,14 @@ function ConnectionTypeInfo({ onClose }: { onClose: () => void }) {
</div>
</div>
<p className="text-foreground font-medium pt-1">
Good news: All types have the same monthly price. We&apos;ll check what&apos;s available
at your address.
Good news: All types have the same monthly price (¥4,800~). We&apos;ll check what&apos;s
available at your address.
</p>
</div>
</div>
);
}
/**
* Tier card component
*/
function TierCard({ tier }: { tier: TierInfo }) {
const styles = tierStyles[tier.tier];
const isGold = tier.tier === "Gold";
return (
<div
className={cn(
"rounded-xl border p-4 transition-all duration-200 flex flex-col relative",
styles.card,
"hover:-translate-y-0.5 hover:shadow-md"
)}
>
{/* Recommended badge for Gold */}
{isGold && (
<div className="absolute -top-2.5 left-4">
<span className="inline-flex items-center gap-1 text-[10px] font-bold bg-warning text-warning-foreground px-2 py-0.5 rounded-full shadow-sm">
<Star className="h-3 w-3" fill="currentColor" />
Popular
</span>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between mb-3">
<span className={cn("font-bold text-sm", styles.accent)}>{tier.tier}</span>
</div>
{/* Price */}
<div className="mb-3">
<div className="flex items-baseline gap-0.5 flex-wrap">
<span className="text-2xl font-bold text-foreground">
¥{tier.monthlyPrice.toLocaleString()}
</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
{tier.pricingNote && (
<span className="text-[10px] text-warning font-medium">{tier.pricingNote}</span>
)}
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">{tier.description}</p>
{/* Features */}
<ul className="space-y-2 flex-grow">
{tier.features.slice(0, 3).map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-xs">
<Check className="h-3.5 w-3.5 text-success flex-shrink-0 mt-0.5" />
<span className="text-muted-foreground leading-relaxed">{feature}</span>
</li>
))}
</ul>
</div>
);
}
/**
* Public-facing offering card that shows pricing inline
* No modals - all information is visible or expandable within the card
@ -179,6 +116,7 @@ export function PublicOfferingCard({
description,
iconType,
startingPrice,
maxPrice,
setupFee,
tiers,
isPremium = false,
@ -196,82 +134,70 @@ export function PublicOfferingCard({
return (
<div
className={cn(
"rounded-2xl border overflow-hidden",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:shadow-lg",
isExpanded ? "shadow-md ring-1 ring-primary/10" : "shadow-sm",
isPremium
? "border-primary/30 bg-gradient-to-r from-primary/5 to-card"
: "border-border bg-card"
"rounded-xl border bg-card shadow-[var(--cp-shadow-1)] overflow-hidden transition-all duration-300 hover:shadow-lg hover:-translate-y-0.5",
isExpanded ? "shadow-[var(--cp-shadow-2)] ring-1 ring-primary/20" : "",
isPremium ? "border-primary/30" : "border-border"
)}
>
{/* Header - Always visible */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full p-5 flex items-start justify-between gap-4 text-left hover:bg-muted/30 transition-colors"
className="w-full p-4 flex items-start justify-between gap-3 text-left hover:bg-muted/20 transition-colors"
>
<div className="flex items-start gap-4">
<div className="flex items-start gap-3">
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-xl border flex-shrink-0",
"transition-transform duration-[var(--cp-duration-normal)]",
isExpanded && "scale-105",
"flex h-10 w-10 items-center justify-center rounded-lg border flex-shrink-0",
iconType === "home"
? "bg-blue-500/10 text-blue-500 border-blue-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
? "bg-info-soft/50 text-info border-info/20"
: "bg-success-soft/50 text-success border-success/20"
)}
>
<Icon className="h-6 w-6" />
<Icon className="h-5 w-5" />
</div>
<div className="space-y-1.5">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-bold text-foreground">{title}</h3>
<h3 className="text-base font-bold text-foreground">{title}</h3>
<CardBadge text={speedBadge} variant={isPremium ? "new" : "default"} size="sm" />
{isPremium && <span className="text-xs text-muted-foreground">(select areas)</span>}
</div>
<p className="text-sm text-muted-foreground">{description}</p>
<div className="flex items-baseline gap-1.5 pt-1">
<div className="flex items-baseline gap-1 pt-0.5">
<span className="text-xs text-muted-foreground">From</span>
<span className="text-xl font-bold text-foreground">
<span className="text-lg font-bold text-foreground">
¥{startingPrice.toLocaleString()}
{maxPrice && maxPrice > startingPrice && `~${maxPrice.toLocaleString()}`}
</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0 mt-1">
<div className="flex items-center gap-1.5 flex-shrink-0 mt-1">
<span className="text-xs text-muted-foreground hidden sm:inline">
{isExpanded ? "Hide tiers" : "View tiers"}
{isExpanded ? "Hide" : "View tiers"}
</span>
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
"bg-muted/50 text-muted-foreground",
"transition-all duration-[var(--cp-duration-normal)]",
isExpanded && "bg-primary/10 text-primary rotate-180"
)}
>
<ChevronDown className="h-4 w-4" />
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded content - Tier pricing shown inline */}
{isExpanded && (
<div className="border-t border-border px-5 py-5 bg-muted/20">
<div className="border-t border-border px-4 py-4 bg-muted/10">
{/* Connection type info button (for Apartment) */}
{showConnectionInfo && !showInfo && (
<button
type="button"
onClick={() => setShowInfo(true)}
className="flex items-center gap-2 text-xs text-info hover:text-info/80 transition-colors mb-4 group"
className="flex items-center gap-1.5 text-xs text-info hover:text-info/80 transition-colors mb-3"
>
<div className="h-6 w-6 rounded-md bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors">
<Info className="h-3.5 w-3.5" />
</div>
<Info className="h-4 w-4" />
<span>Why does speed vary by building?</span>
</button>
)}
@ -282,26 +208,94 @@ export function PublicOfferingCard({
)}
{/* Tier cards - 3 columns on desktop */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-5">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
{tiers.map(tier => (
<TierCard key={tier.tier} tier={tier} />
<div
key={tier.tier}
className={cn(
"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={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>
</div>
{/* Price - Always visible */}
<div className="mb-2">
<div className="flex items-baseline gap-0.5 flex-wrap">
<span className="text-xl 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>
{tier.pricingNote && (
<span
className={`text-[10px] ml-1 ${tier.tier === "Platinum" ? "text-primary" : "text-amber-600"}`}
>
{tier.pricingNote}
</span>
)}
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mb-2">{tier.description}</p>
{/* Features */}
<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">
<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>
))}
</ul>
</div>
))}
</div>
{/* Footer with setup fee and CTA */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 pt-4 border-t border-border/50">
<p className="text-sm text-muted-foreground flex-1">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 pt-3 border-t border-border/50">
<p className="text-xs text-muted-foreground flex-1">
<span className="font-semibold text-foreground">
+ ¥{setupFee.toLocaleString()} one-time setup
</span>{" "}
(or 12/24-month installment)
</p>
{onCtaClick ? (
<Button onClick={onCtaClick} className="whitespace-nowrap">
<Button as="button" onClick={onCtaClick} size="sm" className="whitespace-nowrap">
{customCtaLabel ?? "Check availability"}
</Button>
) : (
<Button as="a" href={ctaPath} className="whitespace-nowrap">
<Button as="a" href={ctaPath} size="sm" className="whitespace-nowrap">
{customCtaLabel ?? "Check availability"}
</Button>
)}

View File

@ -0,0 +1,344 @@
"use client";
import { useState, useMemo } from "react";
import { Search, Smartphone, Check, X } from "lucide-react";
import { cn } from "@/shared/utils";
// Device categories with their devices
const DEVICE_CATEGORIES = [
{
name: "Apple iPhone",
devices: [
"iPhone 16 Series (Standard/Plus/Pro/Pro Max)",
"iPhone 15 Series (Standard/Plus/Pro/Pro Max)",
"iPhone 14 Series (Standard/Plus/Pro/Pro Max)",
"iPhone SE (3rd Generation, 2022)",
"iPhone 13 Series (Standard/Mini/Pro/Pro Max)",
"iPhone 12 Series (Standard/Mini/Pro/Pro Max)",
"iPhone SE (2nd Generation, 2020)",
"iPhone 11 Series (Standard/Pro/Pro Max)",
"iPhone XS Series (Standard/Max)",
"iPhone XR",
"iPhone X (4G Only)",
"iPhone 8 / 8 Plus (4G Only)",
"iPhone 7 / 7 Plus (4G Only)",
"iPhone 6s / 6s Plus (4G Only)",
],
},
{
name: "Apple iPad",
devices: [
"iPad Pro 13-inch (M4)",
'iPad Pro 12.9" (6th/5th/4th/3rd Generations)',
'iPad Pro 11" (4th/3rd/2nd/1st Generations)',
"iPad Air 13-inch (M2)",
"iPad Air (5th/4th/3rd Generations)",
"iPad Mini (6th/5th Generations, A17 Pro)",
"iPad Standard (10th/9th/8th/7th Generations)",
],
},
{
name: "Google Pixel",
devices: [
"Pixel 9 Series (Pro XL/Pro/Fold/Standard)",
"Pixel 8 / 8a / 8 Pro",
"Pixel 7a / 7 / 7 Pro",
"Pixel Fold",
"Pixel 6a / 6 / 6 Pro",
"Pixel 5a (5G) / 5",
"Pixel 4a (5G) / 4a",
"Pixel 4 XL / 4",
"Pixel 3a XL / 3a",
"Pixel 3 XL / 3",
],
},
{
name: "Samsung Galaxy S Series",
devices: [
"Galaxy S25 Edge",
"Galaxy S24 Ultra / S24 / S24 FE",
"Galaxy S23 Ultra / S23 / S23 FE",
"Galaxy S22 Ultra 5G / S22+ 5G / S22 5G",
"Galaxy S21 Ultra 5G / S21+ 5G / S21 5G",
"Galaxy S20 Ultra / S20+ 5G / S20+ / S20 5G / S20",
"Galaxy S10",
"Galaxy S7 edge / S6 / S6 edge",
"Galaxy S5 ACTIVE / S5 / S4",
],
},
{
name: "Samsung Galaxy Z / Note / A Series",
devices: [
"Galaxy Z Fold 6 / 5 / 4 / 3 / 2",
"Galaxy Z Flip 6 / 5 / 4 / 3 5G",
"Galaxy Note 20 Ultra 5G / Note 20 5G",
"Galaxy A56 5G / A55 5G / A54 5G / A53 5G",
"Galaxy A52s 5G / A51 5G / A35 5G / A23 5G",
"Galaxy M23 5G",
],
},
{
name: "Sony Xperia",
devices: [
"Xperia 1 VI / 1 V / 1 IV / 1 III / 1 II",
"Xperia 5 V / 5 IV / 5 III / 5 II",
"Xperia 10 VI / 10 V / 10 IV / 10 III Lite",
"Xperia Pro-I / Pro",
"Xperia Ace III / Ace II / Ace",
"Xperia 8 Lite / XZ Premium",
"Xperia X Performance / Z5 Premium / Z5 Compact / Z5",
"Xperia Z4 / Z3 Compact / Z2 / Z",
],
},
{
name: "Sharp AQUOS",
devices: ["AQUOS R9 / R8 / R7", "AQUOS sense9 / sense8 / sense7", "AQUOS wish4 / wish3"],
},
{
name: "Xiaomi / Redmi",
devices: [
"Xiaomi 14T Pro / 14T / 14 Ultra / 14 Pro / 14 Pro+",
"Xiaomi 13T Pro / 13T / 13 Pro / 13 / 13 Lite",
"Xiaomi 12T Pro",
"Redmi Note 14 Pro / 13 Pro+ / 13 Pro 5G",
"Redmi Note 11 Pro 5G / 10T",
"Redmi 12 5G",
],
},
{
name: "Motorola",
devices: [
"Edge 50 Ultra / 50s Pro / 50 Pro / 50 Neo / 50 Fusion",
"Edge 40 Pro / 40 Neo / 40",
"Edge+ (2024) / Edge+ (2023)",
"Razr 50 Ultra / 50 / 40 Ultra / 40",
"Razr 2024 / 2022 / 5G / 2019",
"Moto G85 / G64y 5G / G55 / G54 / G35",
"Moto G53J 5G / G52J 5G",
"ThinkPhone 25",
],
},
{
name: "OPPO / OnePlus",
devices: [
"OPPO Find X8 / X5 Pro / X5 / X3 Pro",
"OPPO Find N2 Flip",
"OPPO Reno11 A / 10 Pro 5G / 9 A / 7 A",
"OPPO Reno6 Pro 5G / Reno 5 A / Reno A",
"OPPO A79 5G / A73 / A55s 5G / A3 5G",
"OnePlus 13 / 12 / 11",
],
},
{
name: "ASUS",
devices: [
"Zenfone 9 / 8 Flip / 8",
"ROG Phone 7 / 6 / 5 / 3 / II",
"ZenFone 7 Pro / 7 / 6",
"ZenFone 5Z / 5 / 5Q",
"ZenFone 4 Series / 3 Series",
"ZenFone Max Series",
],
},
{
name: "Vivo / Nokia",
devices: ["Vivo X100 Pro / X90 Pro", "Vivo V40 / V29 / V29 Lite 5G", "Nokia XR21 / X30 / G60"],
},
{
name: "HUAWEI",
devices: [
"P40 Pro 5G / P40 / P40 lite 5G / P40 lite E",
"Mate 40 Pro+ / Mate 40 Pro / Mate 40",
"Mate 20 Pro / Mate 20 lite / Mate 10 Pro",
"P30 / P30 lite / P20 / P20 lite",
"nova 5T / nova lite 3+ / nova lite 3 / nova 3",
"MediaPad M5 / M3 / T5 Series",
],
},
{
name: "Fujitsu arrows",
devices: [
"arrows We2 Plus / We2 / N",
"arrows NX9 F-52A",
"arrows M05 / M04 / M03 / M02",
"arrows SV F-03H / NX F-02H",
],
},
{
name: "Other Devices",
devices: [
"DuraForce EX KY-51D / PRO",
"Kids Phones (Compact/KY-41C/SH-03M)",
"ASUS Chromebook CM30 Detachable",
"dtab Compact (d-52C/d-42A) / Standard (d-51C)",
"Essential Phone PH-1",
"HTC U12+ / U11 / U11 life",
"CAT S60 / S41 / S40",
"BlackBerry PRIV / Passport / Classic",
"Microsoft Surface Pro LTE / Surface 3 (4G)",
"Lenovo Tab4 8 / YOGA Series",
"LG Nexus 5X / Nexus 5",
],
},
];
// Flatten all devices for search
const ALL_DEVICES = DEVICE_CATEGORIES.flatMap(category =>
category.devices.map(device => ({
device,
category: category.name,
}))
);
export function DeviceCompatibility() {
const [searchQuery, setSearchQuery] = useState("");
const [isExpanded, setIsExpanded] = useState(false);
const filteredDevices = useMemo(() => {
if (!searchQuery.trim()) return [];
const query = searchQuery.toLowerCase();
return ALL_DEVICES.filter(
item =>
item.device.toLowerCase().includes(query) || item.category.toLowerCase().includes(query)
).slice(0, 20); // Limit results for performance
}, [searchQuery]);
const hasResults = filteredDevices.length > 0;
const showNoResults = searchQuery.trim().length > 0 && !hasResults;
return (
<section className="mt-12 mb-8">
<h2 className="text-2xl font-bold text-foreground mb-2 text-center">Device Compatibility</h2>
<p className="text-sm text-muted-foreground text-center mb-6">
Check if your device is compatible with our SIM service
</p>
{/* Search Box */}
<div className="max-w-xl mx-auto mb-6">
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search your device (e.g., iPhone 15, Galaxy S24, Pixel 8)"
className="w-full pl-12 pr-4 py-3 rounded-xl border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all"
/>
</div>
{/* Search Results */}
{searchQuery.trim() && (
<div className="mt-3 rounded-xl border border-border bg-card overflow-hidden">
{hasResults ? (
<div className="divide-y divide-border">
{filteredDevices.map((item, index) => (
<div
key={index}
className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">{item.device}</p>
<p className="text-xs text-muted-foreground">{item.category}</p>
</div>
<span className="text-xs font-medium text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded-full">
Compatible
</span>
</div>
))}
{filteredDevices.length === 20 && (
<p className="text-xs text-muted-foreground text-center py-2">
Showing first 20 results. Try a more specific search.
</p>
)}
</div>
) : showNoResults ? (
<div className="p-6 text-center">
<div className="flex-shrink-0 h-12 w-12 mx-auto rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center mb-3">
<X className="h-6 w-6 text-amber-600 dark:text-amber-400" />
</div>
<p className="font-medium text-foreground mb-1">Device not found in our list</p>
<p className="text-sm text-muted-foreground">
Your device may still be compatible. Please{" "}
<a href="mailto:info@asolutions.co.jp" className="text-primary hover:underline">
contact us
</a>{" "}
to verify compatibility.
</p>
</div>
) : null}
</div>
)}
</div>
{/* Expandable Full Device List */}
<div className="max-w-3xl mx-auto">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary hover:text-primary/80 transition-colors"
>
<Smartphone className="h-4 w-4" />
{isExpanded ? "Hide full device list" : "View all compatible devices"}
</button>
{isExpanded && (
<div className="mt-4 rounded-xl border border-border bg-card overflow-hidden">
<div className="p-4 bg-muted/30 border-b border-border">
<p className="text-sm text-muted-foreground">
Below is a comprehensive list of devices confirmed to work with our SIM service.
Devices not listed may still be compatible.
</p>
</div>
<div className="divide-y divide-border">
{DEVICE_CATEGORIES.map((category, catIndex) => (
<DeviceCategorySection key={catIndex} category={category} />
))}
</div>
</div>
)}
</div>
</section>
);
}
function DeviceCategorySection({ category }: { category: (typeof DEVICE_CATEGORIES)[number] }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between gap-3 p-4 text-left hover:bg-muted/50 transition-colors"
>
<span className="font-medium text-foreground">{category.name}</span>
<span
className={cn(
"text-xs text-muted-foreground transition-transform",
isOpen && "rotate-180"
)}
>
</span>
</button>
{isOpen && (
<div className="px-4 pb-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{category.devices.map((device, index) => (
<div key={index} className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
<span>{device}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
export default DeviceCompatibility;

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

@ -2,100 +2,80 @@
import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms/button";
import { ArrowRight, Check, 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";
import { cn } from "@/shared/utils/cn";
import { getVpnRegionConfig } from "@/features/services/utils";
interface VpnPlanCardProps {
plan: VpnCatalogProduct;
}
const vpnFeatures = [
"Secure VPN connection",
"Pre-configured router",
"Easy plug & play setup",
"English support included",
];
export function VpnPlanCard({ plan }: VpnPlanCardProps) {
const region = getVpnRegionConfig(plan.name);
const isUS = region.accent === "blue";
const isUK = region.accent === "red";
return (
<AnimatedCard
className={cn(
"p-6 transition-all duration-300 hover:shadow-lg flex flex-col h-full",
"border hover:-translate-y-0.5",
isUS && "border-blue-500/30 hover:border-blue-500/50",
isUK && "border-red-500/30 hover:border-red-500/50",
!isUS && !isUK && "border-primary/20 hover:border-primary/40"
)}
>
{/* Header with flag and region */}
<div className="flex items-start gap-4 mb-5">
{/* Flag/Icon */}
<div
className={cn(
"w-14 h-14 rounded-xl flex items-center justify-center text-2xl",
isUS && "bg-blue-500/10",
isUK && "bg-red-500/10",
!isUS && !isUK && "bg-primary/10"
)}
role="img"
aria-label={region.flagAlt}
>
{region.flag}
<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 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>
{/* Title and location */}
<div className="flex-1">
<h3 className="text-xl font-semibold text-foreground">{plan.name}</h3>
<p className="text-sm text-muted-foreground">{region.location}</p>
</div>
{/* Shield icon */}
<div
className={cn(
"p-2 rounded-lg",
isUS && "bg-blue-500/10 text-blue-600",
isUK && "bg-red-500/10 text-red-600",
!isUS && !isUK && "bg-primary/10 text-primary"
)}
>
<ShieldCheck className="h-5 w-5" />
<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-5">
<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 */}
<div className="flex-1 mb-5">
<ul className="space-y-2">
{region.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<Check
className={cn(
"h-4 w-4 mt-0.5 flex-shrink-0",
isUS && "text-blue-500",
isUK && "text-red-500",
!isUS && !isUK && "text-primary"
)}
<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"
/>
<span className="text-muted-foreground">{feature}</span>
</li>
))}
</ul>
</div>
</svg>
<span className="text-muted-foreground">{feature}</span>
</li>
))}
</ul>
{/* Action Button */}
<div className="mt-auto">
<Button
as="a"
href={`/order?type=VPN&planSku=${encodeURIComponent(plan.sku)}`}
href={`/order?type=vpn&planSku=${encodeURIComponent(plan.sku)}`}
className="w-full"
rightIcon={<ArrowRight className="w-4 h-4" />}
>
Select {region.region}
Select {plan.name}
</Button>
</div>
</AnimatedCard>

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +1,265 @@
"use client";
import {
ShieldCheck,
Router,
Globe,
Tv,
Wifi,
Package,
Headphones,
CreditCard,
Play,
} from "lucide-react";
import { usePublicVpnCatalog } from "@/features/services/hooks";
import { VpnPlansContent } from "@/features/services/components/vpn/VpnPlansContent";
import { LoadingCard } from "@/components/atoms";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
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
*
* Thin wrapper that provides data to VpnPlansContent with variant="public".
* Uses public catalog hook for unauthenticated users.
* Displays VPN plans for unauthenticated users.
*/
export function PublicVpnPlansView() {
const servicesBasePath = useServicesBasePath();
const { data, error } = usePublicVpnCatalog();
const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || [];
// Simple loading check: show skeleton until we have data or an error
const isLoading = !data && !error;
if (isLoading || error) {
return (
<div className="max-w-6xl mx-auto px-4">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<AsyncBlock
isLoading={isLoading}
error={error}
loadingText="Loading VPN plans..."
variant="page"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{Array.from({ length: 4 }).map((_, index) => (
<LoadingCard key={index} className="h-64" />
))}
</div>
</AsyncBlock>
</div>
);
}
const vpnFeatures: HighlightFeature[] = [
{
icon: <Router className="h-6 w-6" />,
title: "Zero Setup Required",
description: "Router arrives pre-configured. Just plug in and you're connected",
highlight: "Plug & play",
},
{
icon: <Tv className="h-6 w-6" />,
title: "Stream from Home",
description: "Watch Netflix, Hulu, BBC iPlayer and more from the US or UK",
highlight: "Your content",
},
{
icon: <Globe className="h-6 w-6" />,
title: "US & UK Servers",
description: "Choose San Francisco for US content or London for UK content",
highlight: "2 regions",
},
{
icon: <Wifi className="h-6 w-6" />,
title: "Dedicated VPN WiFi",
description: "Separate network for VPN. Your regular internet stays fast",
highlight: "No slowdown",
},
{
icon: <Package className="h-6 w-6" />,
title: "All-Inclusive Rental",
description: "Router rental included in your monthly fee. Nothing extra to buy",
highlight: "Simple pricing",
},
{
icon: <Headphones className="h-6 w-6" />,
title: "English Support",
description: "Questions? Our English-speaking team is here to help",
highlight: "We speak your language",
},
];
return (
<div className="max-w-6xl mx-auto px-4">
<VpnPlansContent
variant="public"
plans={vpnPlans}
activationFees={activationFees}
isLoading={isLoading}
error={error}
<div className="max-w-6xl mx-auto px-4 pb-16">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<ServicesHero
title="Stream Your Favorites from Home"
description="Missing shows from back home? Our VPN lets you watch US and UK content in Japan. Pre-configured router, just plug in and stream."
/>
{/* Service Highlights */}
<ServiceHighlights features={vpnFeatures} className="mb-12" />
{vpnPlans.length > 0 ? (
<div className="mb-8">
<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 => (
<VpnPlanCard key={plan.id} plan={plan} />
))}
</div>
{activationFees.length > 0 && (
<AlertBanner variant="info" className="mt-6 max-w-4xl mx-auto" title="Activation Fee">
A one-time activation fee of ¥3,000 applies per router rental. Tax (10%) not included.
</AlertBanner>
)}
</div>
) : (
<div className="text-center py-12">
<ShieldCheck className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">No VPN Plans Available</h3>
<p className="text-muted-foreground mb-6">
We couldn&apos;t find any VPN plans available at this time.
</p>
<ServicesBackLink
href={servicesBasePath}
label="Back to Services"
align="center"
className="mt-4 mb-0"
/>
</div>
)}
{/* How It Works Section */}
<VpnHowItWorksSection />
<AlertBanner variant="warning" title="Important Disclaimer" className="mb-8">
<p className="text-sm">
Content subscriptions are NOT included in the VPN package. Our VPN service establishes a
network connection that virtually locates you in the designated server location. Not all
services can be unblocked. We do not guarantee access to any specific website or streaming
service quality.
</p>
</AlertBanner>
</div>
);
}
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

@ -16,89 +16,21 @@ import { formatIsoDate } from "@/shared/utils";
// Re-export for backwards compatibility
export type { SimDetails };
// CSS class constants to avoid duplication
const TEXT_SUCCESS = "text-success";
const TEXT_MUTED_FOREGROUND = "text-muted-foreground";
// Inline formatPlanShort function
function formatPlanShort(planCode?: string): string {
if (!planCode) return "—";
const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
if (m?.[1]) {
if (m && m[1]) {
return `${m[1]}G`;
}
// Try extracting trailing number+G anywhere in the string
const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
if (m2?.[1]) {
if (m2 && m2[1]) {
return `${m2[1]}G`;
}
return planCode;
}
function formatPlan(code?: string): string {
const formatted = formatPlanShort(code);
// Remove "PASI" prefix if present
return formatted?.replace(/^PASI\s*/, "") || formatted;
}
function formatDate(dateString: string): string {
const formatted = formatIsoDate(dateString, { fallback: dateString, dateStyle: "medium" });
return formatted === "Invalid date" ? dateString : formatted;
}
function formatQuota(quotaMb: number): string {
if (quotaMb >= 1000) {
return `${(quotaMb / 1000).toFixed(1)} GB`;
}
return `${quotaMb.toFixed(0)} MB`;
}
const STATUS_CONFIG = {
active: {
icon: CheckCircleIcon,
iconClass: TEXT_SUCCESS,
badgeClass: `bg-success-soft ${TEXT_SUCCESS}`,
},
suspended: {
icon: ExclamationTriangleIcon,
iconClass: "text-warning",
badgeClass: "bg-warning-soft text-warning",
},
cancelled: {
icon: XCircleIcon,
iconClass: "text-danger",
badgeClass: "bg-danger-soft text-danger",
},
pending: {
icon: ClockIcon,
iconClass: "text-info",
badgeClass: "bg-info-soft text-info",
},
default: {
icon: DevicePhoneMobileIcon,
iconClass: TEXT_MUTED_FOREGROUND,
badgeClass: `bg-muted ${TEXT_MUTED_FOREGROUND}`,
},
} as const;
function getStatusConfig(status: string): (typeof STATUS_CONFIG)[keyof typeof STATUS_CONFIG] {
return STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.default;
}
function StatusIcon({ status }: { status: string }): React.JSX.Element {
const config = getStatusConfig(status);
const Icon = config.icon;
return <Icon className={`h-6 w-6 ${config.iconClass}`} />;
}
function getStatusBadgeClass(status: string): string {
return getStatusConfig(status).badgeClass;
}
function getCardContainerClass(embedded: boolean): string {
return embedded ? "" : "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border";
}
interface SimDetailsCardProps {
simDetails: SimDetails;
isLoading?: boolean;
@ -107,209 +39,207 @@ interface SimDetailsCardProps {
showFeaturesSummary?: boolean; // show the right-side Service Features summary
}
function SimDetailsLoadingSkeleton({ embedded }: { embedded: boolean }): React.JSX.Element {
const containerClass = embedded
? ""
: "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border hover:shadow-md transition-shadow duration-[var(--cp-transition-normal)] ";
return (
<div className={`${containerClass}p-[var(--cp-card-padding)] lg:p-[var(--cp-card-padding-lg)]`}>
<div className="animate-pulse">
<div className="flex items-center space-x-4">
<div className="rounded-full bg-gradient-to-br from-primary-soft to-accent-soft h-14 w-14"></div>
<div className="flex-1 space-y-3">
<div className="h-5 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-3/4"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-1/2"></div>
</div>
</div>
<div className="mt-8 space-y-4">
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)]"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-5/6"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-4/6"></div>
</div>
</div>
</div>
);
}
function SimDetailsError({
embedded,
export function SimDetailsCard({
simDetails,
isLoading,
error,
}: {
embedded: boolean;
error: string;
}): React.JSX.Element {
const containerClass = embedded
? ""
: "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-danger-soft ";
return (
<div className={`${containerClass}p-[var(--cp-card-padding)] lg:p-[var(--cp-card-padding-lg)]`}>
<div className="text-center">
<div className="bg-danger-soft rounded-full p-3 w-16 h-16 mx-auto mb-4">
<ExclamationTriangleIcon className="h-10 w-10 text-danger mx-auto" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">Error Loading SIM Details</h3>
<p className="text-danger text-sm">{error}</p>
</div>
</div>
);
}
embedded = false,
showFeaturesSummary = true,
}: SimDetailsCardProps) {
const formatPlan = (code?: string) => {
const formatted = formatPlanShort(code);
// Remove "PASI" prefix if present
return formatted?.replace(/^PASI\s*/, "") || formatted;
};
const getStatusIcon = (status: string) => {
switch (status) {
case "active":
return <CheckCircleIcon className="h-6 w-6 text-success" />;
case "suspended":
return <ExclamationTriangleIcon className="h-6 w-6 text-warning" />;
case "cancelled":
return <XCircleIcon className="h-6 w-6 text-danger" />;
case "pending":
return <ClockIcon className="h-6 w-6 text-info" />;
default:
return <DevicePhoneMobileIcon className="h-6 w-6 text-muted-foreground" />;
}
};
function capitalizeStatus(status: string): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
const getStatusColor = (status: string) => {
switch (status) {
case "active":
return "bg-success-soft text-success";
case "suspended":
return "bg-warning-soft text-warning";
case "cancelled":
return "bg-danger-soft text-danger";
case "pending":
return "bg-info-soft text-info";
default:
return "bg-muted text-muted-foreground";
}
};
interface UsageDonutProps {
remainingGB: number;
usagePercentage: number;
size?: number;
}
const formatDate = (dateString: string) => {
const formatted = formatIsoDate(dateString, { fallback: dateString, dateStyle: "medium" });
return formatted === "Invalid date" ? dateString : formatted;
};
function UsageDonut({
remainingGB,
usagePercentage,
size = 120,
}: UsageDonutProps): React.JSX.Element {
const radius = (size - 16) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (usagePercentage / 100) * circumference;
const formatQuota = (quotaMb: number) => {
if (quotaMb >= 1000) {
return `${(quotaMb / 1000).toFixed(1)} GB`;
}
return `${quotaMb.toFixed(0)} MB`;
};
return (
<div className="relative flex items-center justify-center">
<svg width={size} height={size} className="transform -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="8"
className="text-muted"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="text-primary transition-all duration-[var(--cp-transition-normal)]"
/>
</svg>
<div className="absolute text-center">
<div className="text-3xl font-semibold text-foreground">{remainingGB.toFixed(1)}</div>
<div className={`text-sm ${TEXT_MUTED_FOREGROUND} -mt-1`}>GB remaining</div>
<div className={`text-xs ${TEXT_MUTED_FOREGROUND} mt-1`}>
{usagePercentage.toFixed(1)}% used
</div>
</div>
</div>
);
}
const MOCK_USAGE_HISTORY = [
{ date: "Sep 29", usage: "0 MB" },
{ date: "Sep 28", usage: "0 MB" },
{ date: "Sep 27", usage: "0 MB" },
] as const;
function EsimDetailsView({
simDetails,
embedded,
}: {
simDetails: SimDetails;
embedded: boolean;
}): React.JSX.Element {
const remainingGB = simDetails.remainingQuotaMb / 1000;
const totalGB = 1048.6; // Mock total - should come from API
const usedGB = totalGB - remainingGB;
const usagePercentage = (usedGB / totalGB) * 100;
const headerClass = embedded
? ""
: "px-[var(--cp-space-6)] py-[var(--cp-space-4)] border-b border-border";
const contentClass = embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-6)]";
return (
<div className={getCardContainerClass(embedded)}>
<div className={headerClass}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
className={`inline-flex px-3 py-1 text-xs font-medium rounded-full ${getStatusBadgeClass(simDetails.status)}`}
>
{capitalizeStatus(simDetails.status)}
</span>
<span className="text-lg font-semibold text-foreground">
{formatPlan(simDetails.planCode)}
</span>
if (isLoading) {
const Skeleton = (
<div
className={`${embedded ? "" : "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border hover:shadow-md transition-shadow duration-[var(--cp-transition-normal)] "}p-[var(--cp-card-padding)] lg:p-[var(--cp-card-padding-lg)]`}
>
<div className="animate-pulse">
<div className="flex items-center space-x-4">
<div className="rounded-full bg-gradient-to-br from-primary-soft to-accent-soft h-14 w-14"></div>
<div className="flex-1 space-y-3">
<div className="h-5 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-3/4"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-1/2"></div>
</div>
</div>
</div>
<div className={`text-sm ${TEXT_MUTED_FOREGROUND} mt-1`}>{simDetails.msisdn}</div>
</div>
<div className={contentClass}>
<div className="flex justify-center mb-6">
<UsageDonut remainingGB={remainingGB} usagePercentage={usagePercentage} size={160} />
</div>
<div className="border-t border-border pt-4">
<h4 className="text-sm font-medium text-foreground mb-3">Recent Usage History</h4>
<div className="space-y-2">
{MOCK_USAGE_HISTORY.map((entry, index) => (
<div key={index} className="flex justify-between items-center text-xs">
<span className={TEXT_MUTED_FOREGROUND}>{entry.date}</span>
<span className="text-foreground">{entry.usage}</span>
</div>
))}
<div className="mt-8 space-y-4">
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)]"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-5/6"></div>
<div className="h-4 bg-gradient-to-r from-muted to-muted-hover rounded-[var(--cp-radius-md)] w-4/6"></div>
</div>
</div>
</div>
</div>
);
}
);
return Skeleton;
}
interface FeatureIndicatorProps {
icon: React.ElementType;
enabled: boolean;
label: string;
}
if (error) {
return (
<div
className={`${embedded ? "" : "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-danger-soft "}p-[var(--cp-card-padding)] lg:p-[var(--cp-card-padding-lg)]`}
>
<div className="text-center">
<div className="bg-danger-soft rounded-full p-3 w-16 h-16 mx-auto mb-4">
<ExclamationTriangleIcon className="h-10 w-10 text-danger mx-auto" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">Error Loading SIM Details</h3>
<p className="text-danger text-sm">{error}</p>
</div>
</div>
);
}
function FeatureIndicator({
icon: Icon,
enabled,
label,
}: FeatureIndicatorProps): React.JSX.Element {
const colorClass = enabled ? TEXT_SUCCESS : TEXT_MUTED_FOREGROUND;
// Modern eSIM details view with usage visualization
if (simDetails.simType === "esim") {
const remainingGB = simDetails.remainingQuotaMb / 1000;
const totalGB = 1048.6; // Mock total - should come from API
const usedGB = totalGB - remainingGB;
const usagePercentage = (usedGB / totalGB) * 100;
// Usage Donut Component
const UsageDonut = ({ size = 120 }: { size?: number }) => {
const radius = (size - 16) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (usagePercentage / 100) * circumference;
return (
<div className="relative flex items-center justify-center">
<svg width={size} height={size} className="transform -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="8"
className="text-muted"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="text-primary transition-all duration-[var(--cp-transition-normal)]"
/>
</svg>
<div className="absolute text-center">
<div className="text-3xl font-semibold text-foreground">{remainingGB.toFixed(1)}</div>
<div className="text-sm text-muted-foreground -mt-1">GB remaining</div>
<div className="text-xs text-muted-foreground mt-1">
{usagePercentage.toFixed(1)}% used
</div>
</div>
</div>
);
};
return (
<div
className={`${embedded ? "" : "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border"}`}
>
{/* Compact Header Bar */}
<div
className={`${embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-4)] border-b border-border"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
className={`inline-flex px-3 py-1 text-xs font-medium rounded-full ${getStatusColor(simDetails.status)}`}
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span>
<span className="text-lg font-semibold text-foreground">
{formatPlan(simDetails.planCode)}
</span>
</div>
</div>
<div className="text-sm text-muted-foreground mt-1">{simDetails.msisdn}</div>
</div>
<div className={`${embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-6)]"}`}>
{/* Usage Visualization */}
<div className="flex justify-center mb-6">
<UsageDonut size={160} />
</div>
<div className="border-t border-border pt-4">
<h4 className="text-sm font-medium text-foreground mb-3">Recent Usage History</h4>
<div className="space-y-2">
{[
{ date: "Sep 29", usage: "0 MB" },
{ date: "Sep 28", usage: "0 MB" },
{ date: "Sep 27", usage: "0 MB" },
].map((entry, index) => (
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">{entry.date}</span>
<span className="text-foreground">{entry.usage}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
// Default view for physical SIM cards
return (
<div className="flex items-center">
<Icon className={`h-4 w-4 mr-1 ${colorClass}`} />
<span className={`text-sm ${colorClass}`}>
{label} {enabled ? "Enabled" : "Disabled"}
</span>
</div>
);
}
function PhysicalSimDetailsView({
simDetails,
embedded,
showFeaturesSummary,
}: {
simDetails: SimDetails;
embedded: boolean;
showFeaturesSummary: boolean;
}): React.JSX.Element {
const headerClass = embedded
? ""
: "px-[var(--cp-space-6)] py-[var(--cp-space-4)] border-b border-border";
const contentClass = embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-4)]";
return (
<div className={getCardContainerClass(embedded)}>
<div className={headerClass}>
<div
className={`${embedded ? "" : "bg-card shadow-sm rounded-[var(--cp-card-radius)] border border-border"}`}
>
{/* Header */}
<div
className={`${embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-4)] border-b border-border"}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="text-2xl mr-3">
@ -317,28 +247,118 @@ function PhysicalSimDetailsView({
</div>
<div>
<h3 className="text-lg font-medium text-foreground">Physical SIM Details</h3>
<p className={`text-sm ${TEXT_MUTED_FOREGROUND}`}>
<p className="text-sm text-muted-foreground">
{formatPlan(simDetails.planCode)} {`${simDetails.simType} SIM`}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<StatusIcon status={simDetails.status} />
{getStatusIcon(simDetails.status)}
<span
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusBadgeClass(simDetails.status)}`}
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
>
{capitalizeStatus(simDetails.status)}
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span>
</div>
</div>
</div>
<div className={contentClass}>
{/* Content */}
<div className={`${embedded ? "" : "px-[var(--cp-space-6)] py-[var(--cp-space-4)]"}`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<SimInformationSection simDetails={simDetails} />
{showFeaturesSummary && <ServiceFeaturesSection simDetails={simDetails} />}
{/* SIM Information */}
<div>
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
SIM Information
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Phone Number</label>
<p className="text-sm font-medium text-foreground">{simDetails.msisdn}</p>
</div>
<div>
<label className="text-xs text-muted-foreground">ICCID</label>
<p className="text-sm font-mono text-foreground break-all">{simDetails.iccid}</p>
</div>
{simDetails.eid && (
<div>
<label className="text-xs text-muted-foreground">EID (eSIM)</label>
<p className="text-sm font-mono text-foreground break-all">{simDetails.eid}</p>
</div>
)}
{simDetails.imsi && (
<div>
<label className="text-xs text-muted-foreground">IMSI</label>
<p className="text-sm font-mono text-foreground">{simDetails.imsi}</p>
</div>
)}
{simDetails.activatedAt && (
<div>
<label className="text-xs text-muted-foreground">Service Start Date</label>
<p className="text-sm text-foreground">{formatDate(simDetails.activatedAt)}</p>
</div>
)}
</div>
</div>
{/* Service Features */}
{showFeaturesSummary && (
<div>
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
Service Features
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">Data Remaining</label>
<p className="text-lg font-semibold text-success">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center">
<SignalIcon
className={`h-4 w-4 mr-1 ${simDetails.voiceMailEnabled ? "text-success" : "text-muted-foreground"}`}
/>
<span
className={`text-sm ${simDetails.voiceMailEnabled ? "text-success" : "text-muted-foreground"}`}
>
Voicemail {simDetails.voiceMailEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex items-center">
<DevicePhoneMobileIcon
className={`h-4 w-4 mr-1 ${simDetails.callWaitingEnabled ? "text-success" : "text-muted-foreground"}`}
/>
<span
className={`text-sm ${simDetails.callWaitingEnabled ? "text-success" : "text-muted-foreground"}`}
>
Call Waiting {simDetails.callWaitingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
</div>
<div className="flex items-center">
<WifiIcon
className={`h-4 w-4 mr-1 ${simDetails.internationalRoamingEnabled ? "text-success" : "text-muted-foreground"}`}
/>
<span
className={`text-sm ${simDetails.internationalRoamingEnabled ? "text-success" : "text-muted-foreground"}`}
>
Int&apos;l Roaming{" "}
{simDetails.internationalRoamingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
</div>
</div>
)}
</div>
{/* Expiry Date */}
{simDetails.expiresAt && (
<div className="mt-6 pt-6 border-t border-border">
<div className="flex items-center text-sm">
@ -353,121 +373,3 @@ function PhysicalSimDetailsView({
</div>
);
}
function SimInformationSection({ simDetails }: { simDetails: SimDetails }): React.JSX.Element {
return (
<div>
<h4 className={`text-sm font-medium ${TEXT_MUTED_FOREGROUND} uppercase tracking-wider mb-3`}>
SIM Information
</h4>
<div className="space-y-3">
<SimInfoField label="Phone Number" value={simDetails.msisdn} fontMedium />
<SimInfoField label="ICCID" value={simDetails.iccid} mono breakAll />
{simDetails.eid && <SimInfoField label="EID (eSIM)" value={simDetails.eid} mono breakAll />}
{simDetails.imsi && <SimInfoField label="IMSI" value={simDetails.imsi} mono />}
{simDetails.activatedAt && (
<SimInfoField label="Service Start Date" value={formatDate(simDetails.activatedAt)} />
)}
</div>
</div>
);
}
interface SimInfoFieldProps {
label: string;
value: string;
mono?: boolean;
breakAll?: boolean;
fontMedium?: boolean;
}
function SimInfoField({
label,
value,
mono,
breakAll,
fontMedium,
}: SimInfoFieldProps): React.JSX.Element {
const valueClasses = [
"text-sm",
"text-foreground",
mono && "font-mono",
breakAll && "break-all",
fontMedium && "font-medium",
]
.filter(Boolean)
.join(" ");
return (
<div>
<label className={`text-xs ${TEXT_MUTED_FOREGROUND}`}>{label}</label>
<p className={valueClasses}>{value}</p>
</div>
);
}
function ServiceFeaturesSection({ simDetails }: { simDetails: SimDetails }): React.JSX.Element {
return (
<div>
<h4 className={`text-sm font-medium ${TEXT_MUTED_FOREGROUND} uppercase tracking-wider mb-3`}>
Service Features
</h4>
<div className="space-y-3">
<div>
<label className={`text-xs ${TEXT_MUTED_FOREGROUND}`}>Data Remaining</label>
<p className={`text-lg font-semibold ${TEXT_SUCCESS}`}>
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div>
<div className="flex items-center space-x-4">
<FeatureIndicator
icon={SignalIcon}
enabled={simDetails.voiceMailEnabled}
label="Voicemail"
/>
<FeatureIndicator
icon={DevicePhoneMobileIcon}
enabled={simDetails.callWaitingEnabled}
label="Call Waiting"
/>
</div>
{simDetails.internationalRoamingEnabled && (
<div className="flex items-center">
<WifiIcon className={`h-4 w-4 mr-1 ${TEXT_SUCCESS}`} />
<span className={`text-sm ${TEXT_SUCCESS}`}>International Roaming Enabled</span>
</div>
)}
</div>
</div>
);
}
export function SimDetailsCard({
simDetails,
isLoading,
error,
embedded = false,
showFeaturesSummary = true,
}: SimDetailsCardProps): React.JSX.Element {
if (isLoading) {
return <SimDetailsLoadingSkeleton embedded={embedded} />;
}
if (error) {
return <SimDetailsError embedded={embedded} error={error} />;
}
if (simDetails.simType === "esim") {
return <EsimDetailsView simDetails={simDetails} embedded={embedded} />;
}
return (
<PhysicalSimDetailsView
simDetails={simDetails}
embedded={embedded}
showFeaturesSummary={showFeaturesSummary}
/>
);
}

View File

@ -54,6 +54,15 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Voice feature states
const [featureLoading, setFeatureLoading] = useState<{
voiceMail?: boolean;
callWaiting?: boolean;
internationalRoaming?: boolean;
networkType?: boolean;
}>({});
const [featureError, setFeatureError] = useState<string | null>(null);
// Navigation handlers
const navigateToTopUp = () => router.push(`/account/subscriptions/${subscriptionId}/sim/top-up`);
const navigateToChangePlan = () =>
@ -114,6 +123,88 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
void fetchSimInfo();
}, [fetchSimInfo]);
// Update a single voice feature
const updateFeature = useCallback(
async (
featureKey: "voiceMail" | "callWaiting" | "internationalRoaming" | "networkType",
value: boolean | "4G" | "5G"
) => {
setFeatureLoading(prev => ({ ...prev, [featureKey]: true }));
setFeatureError(null);
try {
const body: {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: "4G" | "5G";
} = {};
switch (featureKey) {
case "voiceMail":
body.voiceMailEnabled = value as boolean;
break;
case "callWaiting":
body.callWaitingEnabled = value as boolean;
break;
case "internationalRoaming":
body.internationalRoamingEnabled = value as boolean;
break;
case "networkType":
body.networkType = value as "4G" | "5G";
break;
}
await apiClient.POST("/api/subscriptions/{id}/sim/features", {
params: { path: { id: subscriptionId } },
body,
});
// Update local state optimistically
setSimInfo(prev => {
if (!prev) return prev;
const updated = { ...prev, details: { ...prev.details } };
switch (featureKey) {
case "voiceMail":
updated.details.voiceMailEnabled = value as boolean;
break;
case "callWaiting":
updated.details.callWaitingEnabled = value as boolean;
break;
case "internationalRoaming":
updated.details.internationalRoamingEnabled = value as boolean;
break;
case "networkType":
updated.details.networkType = value as string;
break;
}
return updated;
});
} catch (err: unknown) {
let errorMessage = "Failed to update feature. Please try again.";
if (err instanceof Error) {
const msg = err.message.toLowerCase();
// Check for rate limiting errors
if (msg.includes("30 minutes") || msg.includes("must be requested")) {
errorMessage = "Please wait 30 minutes between voice/network/plan changes before trying again.";
} else if (msg.includes("another") && msg.includes("in progress")) {
errorMessage = "Another operation is in progress. Please wait a moment.";
} else {
errorMessage = err.message;
}
}
setFeatureError(errorMessage);
// Revert by refetching
void fetchSimInfo();
} finally {
setFeatureLoading(prev => ({ ...prev, [featureKey]: false }));
}
},
[subscriptionId, fetchSimInfo]
);
const handleRefresh = () => {
setLoading(true);
void fetchSimInfo();
@ -215,26 +306,39 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
{/* Voice toggles */}
<div className="space-y-3">
<h4 className="text-md font-semibold text-foreground">Voice Status</h4>
{featureError && (
<div className="p-3 bg-danger-soft border border-danger/25 rounded-lg text-sm text-danger">
{featureError}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<StatusToggle
label="Voice Mail"
subtitle={simInfo.details.voiceMailEnabled ? "Enabled" : "Disabled"}
checked={simInfo.details.voiceMailEnabled || false}
loading={featureLoading.voiceMail}
onChange={checked => void updateFeature("voiceMail", checked)}
/>
<StatusToggle
label="Network Type"
subtitle={simInfo.details.networkType ? simInfo.details.networkType : "Set LTE"}
checked={!!simInfo.details.networkType}
subtitle={simInfo.details.networkType === "5G" ? "5G" : "4G"}
checked={simInfo.details.networkType === "5G"}
loading={featureLoading.networkType}
onChange={checked => void updateFeature("networkType", checked ? "5G" : "4G")}
/>
<StatusToggle
label="Call Waiting"
subtitle={simInfo.details.callWaitingEnabled ? "Enabled" : "Disabled"}
checked={simInfo.details.callWaitingEnabled || false}
loading={featureLoading.callWaiting}
onChange={checked => void updateFeature("callWaiting", checked)}
/>
<StatusToggle
label="International Roaming"
subtitle={simInfo.details.internationalRoamingEnabled ? "Enabled" : "Disabled"}
checked={simInfo.details.internationalRoamingEnabled || false}
loading={featureLoading.internationalRoaming}
onChange={checked => void updateFeature("internationalRoaming", checked)}
/>
</div>
</div>
@ -360,19 +464,36 @@ type StatusToggleProps = {
label: string;
subtitle?: string;
checked: boolean;
onChange?: (checked: boolean) => void;
loading?: boolean;
disabled?: boolean;
};
function StatusToggle({ label, subtitle, checked }: StatusToggleProps) {
function StatusToggle({ label, subtitle, checked, onChange, loading, disabled }: StatusToggleProps) {
const isDisabled = disabled || loading;
const handleClick = () => {
if (!isDisabled && onChange) {
onChange(!checked);
}
};
return (
<div className="p-4 bg-card border border-border rounded-lg">
<div className={`p-4 bg-card border border-border rounded-lg ${isDisabled ? "opacity-60" : ""}`}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">{label}</p>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" checked={checked} className="sr-only peer" readOnly />
<div className="w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
<label className={`relative inline-flex items-center ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"}`}>
<input
type="checkbox"
checked={checked}
onChange={handleClick}
disabled={isDisabled}
className="sr-only peer"
/>
<div className={`w-11 h-6 bg-muted peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-ring/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-background after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-border after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary ${loading ? "animate-pulse" : ""}`}></div>
</label>
</div>
</div>

View File

@ -19,7 +19,6 @@ export function SimChangePlanContainer() {
const subscriptionId = params["id"] as string;
const [plans, setPlans] = useState<SimAvailablePlan[]>([]);
const [selectedPlan, setSelectedPlan] = useState<SimAvailablePlan | null>(null);
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@ -61,7 +60,6 @@ export function SimChangePlanContainer() {
newPlanCode: selectedPlan.freebitPlanCode,
newPlanSku: selectedPlan.sku,
newPlanName: selectedPlan.name,
assignGlobalIp,
});
setMessage(`Plan change scheduled for ${result.scheduledAt || "the 1st of next month"}`);
setSelectedPlan(null);
@ -203,20 +201,6 @@ export function SimChangePlanContainer() {
</div>
</div>
{/* Global IP Option */}
<div className="flex items-center p-4 bg-muted border border-border rounded-lg">
<input
id="globalip"
type="checkbox"
checked={assignGlobalIp}
onChange={e => setAssignGlobalIp(e.target.checked)}
className="h-4 w-4 text-primary border-input rounded focus:ring-ring focus:ring-2"
/>
<label htmlFor="globalip" className="ml-3 text-sm text-foreground/80">
Assign a global IP address (additional charges may apply)
</label>
</div>
{/* Info Box */}
<div className="bg-warning-soft border border-warning/25 rounded-lg p-4">
<h3 className="text-sm font-medium text-foreground mb-1">Important Notes</h3>

View File

@ -6,39 +6,77 @@ import { Button, Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useZodForm } from "@/shared/hooks";
import { Mail, CheckCircle, MapPin } from "lucide-react";
import {
Mail,
CheckCircle,
MapPin,
Phone,
MessageSquare,
ChevronDown,
Clock,
HelpCircle,
Send,
ExternalLink,
} from "lucide-react";
import {
publicContactRequestSchema,
type PublicContactRequest,
} from "@customer-portal/domain/support";
import { apiClient, ApiError, isApiError } from "@/core/api";
import { cn } from "@/shared/utils";
const SEND_ERROR_MESSAGE = "Failed to send message";
const FAQ_ITEMS = [
{
question: "Sample Question 1?",
answer:
"This is a sample answer for frequently asked question 1. Replace this with actual content when available.",
},
{
question: "Sample Question 2?",
answer:
"This is a sample answer for frequently asked question 2. Replace this with actual content when available.",
},
{
question: "Sample Question 3?",
answer:
"This is a sample answer for frequently asked question 3. Replace this with actual content when available.",
},
{
question: "Sample Question 4?",
answer:
"This is a sample answer for frequently asked question 4. Replace this with actual content when available.",
},
{
question: "Sample Question 5?",
answer:
"This is a sample answer for frequently asked question 5. Replace this with actual content when available.",
},
];
/**
* PublicContactView - Contact page with form, phone, chat, and location info
* PublicContactView - Combined Support & Contact page
*/
export function PublicContactView() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [expandedFaq, setExpandedFaq] = useState<number | null>(null);
const handleSubmit = useCallback(async (data: PublicContactRequest) => {
setSubmitError(null);
try {
await apiClient.POST("/api/support/contact", { body: data });
setIsSubmitted(true);
} catch (error) {
if (isApiError(error)) {
setSubmitError(error.message || SEND_ERROR_MESSAGE);
setSubmitError(error.message || "Failed to send message");
return;
}
if (error instanceof ApiError) {
setSubmitError(error.message || SEND_ERROR_MESSAGE);
setSubmitError(error.message || "Failed to send message");
return;
}
setSubmitError(error instanceof Error ? error.message : SEND_ERROR_MESSAGE);
setSubmitError(error instanceof Error ? error.message : "Failed to send message");
}
}, []);
@ -56,17 +94,17 @@ export function PublicContactView() {
if (isSubmitted) {
return (
<div className="max-w-lg mx-auto text-center py-12">
<div className="w-16 h-16 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="h-8 w-8 text-success" />
<div className="max-w-lg mx-auto text-center py-16">
<div className="w-20 h-20 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="h-10 w-10 text-success" />
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Message Sent!</h1>
<p className="text-muted-foreground mb-6">
<h1 className="text-3xl font-bold text-foreground mb-3">Message Sent!</h1>
<p className="text-muted-foreground mb-8 text-lg">
Thank you for contacting us. We&apos;ll get back to you within 24 hours.
</p>
<div className="flex gap-4 justify-center">
<Button as="a" href="/help" variant="outline">
Back to Support
<Button as="a" href="/" variant="outline">
Back to Home
</Button>
<Button as="a" href="/services">
Browse Services
@ -77,224 +115,337 @@ export function PublicContactView() {
}
return (
<div className="max-w-6xl mx-auto px-4">
<div className="max-w-6xl mx-auto px-4 pb-0">
{/* Header */}
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
Get in Touch
<div className="text-center mb-12 pt-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary/10 rounded-2xl mb-4 text-primary">
<HelpCircle className="h-8 w-8" />
</div>
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-4 tracking-tight">
We Speak Your Language
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Have a question about our services? We're here to help you find the perfect solution for
your stay in Japan.
<p className="text-lg text-muted-foreground max-w-2xl mx-auto leading-relaxed">
Have a question about our services? Our English-speaking team is here to help. No Japanese
required. Reach out through any channel below.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
{/* Left Column - Contact Form */}
<div className="lg:col-span-7">
<div className="bg-card rounded-2xl border border-border/60 shadow-sm overflow-hidden">
<div className="p-6 sm:p-8 border-b border-border/60 bg-muted/20">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
<Mail className="h-5 w-5" />
</div>
<div>
<h2 className="text-xl font-bold text-foreground">Send a Message</h2>
<p className="text-sm text-muted-foreground">
We typically reply within 24 hours
</p>
</div>
</div>
{/* Quick Contact Options */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-12">
{/* Phone */}
<a
href="tel:0120-660-470"
className="group bg-white rounded-2xl border border-border/60 p-6 hover:border-primary/40 hover:shadow-md transition-all duration-200"
>
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary group-hover:bg-primary group-hover:text-white transition-colors">
<Phone className="h-6 w-6" />
</div>
<div className="p-6 sm:p-8">
{submitError && (
<AlertBanner variant="error" title="Error" className="mb-6">
{submitError}
</AlertBanner>
)}
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField
label="Name"
error={form.touched["name"] ? form.errors["name"] : undefined}
required
>
<Input
value={form.values.name}
onChange={e => form.setValue("name", e.target.value)}
onBlur={() => form.setTouchedField("name")}
placeholder="Your name"
className="bg-background"
/>
</FormField>
<FormField
label="Email"
error={form.touched["email"] ? form.errors["email"] : undefined}
required
>
<Input
type="email"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouchedField("email")}
placeholder="your@email.com"
className="bg-background"
/>
</FormField>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField
label="Phone (Optional)"
error={form.touched["phone"] ? form.errors["phone"] : undefined}
>
<Input
value={form.values.phone ?? ""}
onChange={e => form.setValue("phone", e.target.value)}
onBlur={() => form.setTouchedField("phone")}
placeholder="+81 90-1234-5678"
className="bg-background"
/>
</FormField>
<FormField
label="Subject"
error={form.touched["subject"] ? form.errors["subject"] : undefined}
required
>
<Input
value={form.values.subject}
onChange={e => form.setValue("subject", e.target.value)}
onBlur={() => form.setTouchedField("subject")}
placeholder="How can we help?"
className="bg-background"
/>
</FormField>
</div>
<FormField
label="Message"
error={form.touched["message"] ? form.errors["message"] : undefined}
required
>
<textarea
className="flex min-h-[160px] w-full rounded-lg border border-input bg-background px-4 py-3 text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-y"
value={form.values.message}
onChange={e => form.setValue("message", e.target.value)}
onBlur={() => form.setTouchedField("message")}
placeholder="Tell us more about your inquiry..."
rows={5}
/>
</FormField>
<Button
type="submit"
className="w-full sm:w-auto min-w-[160px]"
size="lg"
disabled={form.isSubmitting}
isLoading={form.isSubmitting}
loadingText="Sending..."
>
Send Message
</Button>
</form>
<p className="text-xs text-muted-foreground mt-6 pt-6 border-t border-border/60">
By submitting this form, you agree to our{" "}
<Link href="#" className="text-primary hover:underline font-medium">
Privacy Policy
</Link>
. Your information is secure and will only be used to respond to your inquiry.
</p>
<div>
<h3 className="font-bold text-foreground group-hover:text-primary transition-colors">
Call Us
</h3>
<p className="text-lg font-bold text-primary">0120-660-470</p>
<p className="text-xs text-muted-foreground">Toll-free in Japan</p>
</div>
</div>
</div>
</a>
{/* Right Column - Contact Info */}
<div className="lg:col-span-5 space-y-6">
{/* By Phone */}
<div className="bg-card rounded-2xl border border-border/60 p-6 sm:p-8 hover:border-primary/30 transition-colors duration-300">
<h2 className="text-xl font-bold text-foreground mb-4">Phone Support</h2>
<div className="space-y-4">
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">
Japan (Toll Free)
</div>
<a
href="tel:0120-660-470"
className="text-2xl font-bold text-foreground hover:text-primary transition-colors inline-block"
>
0120-660-470
</a>
</div>
<div>
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">
International
</div>
<a
href="tel:+81-3-3560-1006"
className="text-lg font-semibold text-foreground hover:text-primary transition-colors inline-block"
>
+81-3-3560-1006
</a>
</div>
<div className="text-sm text-muted-foreground pt-4 border-t border-border/60">
9:30 - 18:00 JST (Mon - Fri)
</div>
{/* Chat */}
<button
type="button"
onClick={() => {
/* Trigger chat */
}}
className="group bg-white rounded-2xl border border-border/60 p-6 hover:border-blue-500/40 hover:shadow-md transition-all duration-200 text-left"
>
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-xl bg-blue-500/10 flex items-center justify-center text-blue-500 group-hover:bg-blue-500 group-hover:text-white transition-colors">
<MessageSquare className="h-6 w-6" />
</div>
</div>
{/* By Chat */}
<div className="bg-card rounded-2xl border border-border/60 p-6 sm:p-8 hover:border-blue-500/30 transition-colors duration-300">
<h2 className="text-xl font-bold text-foreground mb-4">Live Chat</h2>
<p className="text-muted-foreground mb-6">
Need quick answers? Chat with our support team directly in your browser.
</p>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => {
/* Trigger chat logic would go here */
}}
>
<span className="flex items-center gap-2">
<span className="relative flex h-2 w-2 mr-1">
<div>
<h3 className="font-bold text-foreground group-hover:text-blue-500 transition-colors">
Live Chat
</h3>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-success"></span>
</span>
Chat Available
</span>
</Button>
<span className="text-sm text-muted-foreground">Available now</span>
</div>
</div>
</div>
</button>
{/* Email */}
<a
href="mailto:support@assist-solutions.jp"
className="group bg-white rounded-2xl border border-border/60 p-6 hover:border-emerald-500/40 hover:shadow-md transition-all duration-200"
>
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-500 group-hover:bg-emerald-500 group-hover:text-white transition-colors">
<Mail className="h-6 w-6" />
</div>
<div>
<h3 className="font-bold text-foreground group-hover:text-emerald-500 transition-colors">
Email Us
</h3>
<p className="text-sm text-muted-foreground">support@assist-solutions.jp</p>
</div>
</div>
</a>
</div>
{/* Business Hours Banner */}
<div className="bg-muted/30 rounded-xl p-4 mb-12 flex items-center justify-center gap-3 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
<span className="font-medium text-foreground">Business Hours:</span> Mon - Fri, 9:30 AM -
6:00 PM JST
</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 mb-16">
{/* FAQ Section */}
<div>
<h2 className="text-2xl font-bold text-foreground mb-6 flex items-center gap-2">
<HelpCircle className="h-6 w-6 text-primary" />
Frequently Asked Questions
</h2>
<div className="space-y-3">
{FAQ_ITEMS.map((item, index) => {
const isExpanded = expandedFaq === index;
return (
<div
key={index}
className="bg-white rounded-xl border border-border/60 overflow-hidden"
>
<button
type="button"
onClick={() => setExpandedFaq(isExpanded ? null : index)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/30 transition-colors"
>
<span className="font-medium text-foreground text-sm pr-4">
{item.question}
</span>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground flex-shrink-0 transition-transform",
isExpanded && "rotate-180"
)}
/>
</button>
{isExpanded && (
<div className="px-4 pb-4">
<p className="text-sm text-muted-foreground leading-relaxed">{item.answer}</p>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Contact Form */}
<div>
<h2 className="text-2xl font-bold text-foreground mb-6 flex items-center gap-2">
<Send className="h-6 w-6 text-primary" />
Send a Message
</h2>
<div className="bg-white rounded-2xl border border-border/60 p-6">
{submitError && (
<AlertBanner variant="error" title="Error" className="mb-6">
{submitError}
</AlertBanner>
)}
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FormField
label="Name"
error={form.touched.name ? form.errors.name : undefined}
required
>
<Input
value={form.values.name}
onChange={e => form.setValue("name", e.target.value)}
onBlur={() => form.setTouchedField("name")}
placeholder="Your name"
className="bg-muted/20"
/>
</FormField>
<FormField
label="Email"
error={form.touched.email ? form.errors.email : undefined}
required
>
<Input
type="email"
value={form.values.email}
onChange={e => form.setValue("email", e.target.value)}
onBlur={() => form.setTouchedField("email")}
placeholder="your@email.com"
className="bg-muted/20"
/>
</FormField>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FormField label="Phone" error={form.touched.phone ? form.errors.phone : undefined}>
<Input
value={form.values.phone ?? ""}
onChange={e => form.setValue("phone", e.target.value)}
onBlur={() => form.setTouchedField("phone")}
placeholder="+81 90-1234-5678"
className="bg-muted/20"
/>
</FormField>
<FormField
label="Subject"
error={form.touched.subject ? form.errors.subject : undefined}
required
>
<Input
value={form.values.subject}
onChange={e => form.setValue("subject", e.target.value)}
onBlur={() => form.setTouchedField("subject")}
placeholder="How can we help?"
className="bg-muted/20"
/>
</FormField>
</div>
<FormField
label="Message"
error={form.touched.message ? form.errors.message : undefined}
required
>
<textarea
className="flex min-h-[120px] w-full rounded-lg border border-input bg-muted/20 px-4 py-3 text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-y text-sm"
value={form.values.message}
onChange={e => form.setValue("message", e.target.value)}
onBlur={() => form.setTouchedField("message")}
placeholder="Tell us more about your inquiry..."
rows={4}
/>
</FormField>
<Button
type="submit"
className="w-full"
size="lg"
disabled={form.isSubmitting}
isLoading={form.isSubmitting}
loadingText="Sending..."
>
Send Message
</Button>
</form>
<p className="text-xs text-muted-foreground mt-4 pt-4 border-t border-border/60">
By submitting, you agree to our{" "}
<Link href="#" className="text-primary hover:underline">
Privacy Policy
</Link>
. We typically respond within 24 hours.
</p>
</div>
</div>
</div>
{/* Office Location */}
<div className="bg-white rounded-2xl border border-border/60 overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-2">
{/* Map */}
<div className="h-[300px] lg:h-auto">
<iframe
title="Assist Solutions Corp Office"
src="https://www.google.com/maps?q=Assist+Solutions+Corp,+3-8-2+Higashi+Azabu,+Minato-ku,+Tokyo&output=embed"
className="w-full h-full"
loading="lazy"
allowFullScreen
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
{/* Access / Location */}
<div className="bg-card rounded-2xl border border-border/60 p-6 sm:p-8 hover:border-primary/30 transition-colors duration-300">
{/* Address Info */}
<div className="p-8">
<div className="flex items-center gap-3 mb-6">
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
<MapPin className="h-5 w-5" />
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
<MapPin className="h-6 w-6" />
</div>
<h2 className="text-xl font-bold text-foreground">Visit Us</h2>
</div>
<div className="space-y-4">
<address className="text-muted-foreground leading-relaxed not-italic">
3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu,
<br />
Minato-ku, Tokyo 106-0044
</address>
<div className="pt-4 border-t border-border/60">
<div>
<h2 className="text-xl font-bold text-foreground">Visit Our Office</h2>
<p className="text-sm text-muted-foreground">
Short walk from Exit 6 of Azabu-Juban Station
Walk-ins welcome during business hours
</p>
</div>
</div>
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Address
</h3>
<address className="text-foreground leading-relaxed not-italic">
3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu,
<br />
Minato-ku, Tokyo 106-0044
</address>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Contact
</h3>
<p className="text-foreground">
Tel: 03-3560-1006
<br />
Fax: 03-3560-1007
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Access
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
5 min walk from Exit 6 of Azabu-Juban Station
<br />
(Subway Oedo Line / Nanboku Line)
</p>
</div>
<a
href="https://www.google.com/maps/dir//Assist+Solutions+Corp,+3-8-2+Higashi+Azabu,+Minato-ku,+Tokyo"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-primary font-medium hover:underline"
>
Get Directions
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
</div>
</div>
{/* Existing Customer CTA */}
<div className="text-center mt-12 pt-8 border-t border-border/60">
<p className="text-muted-foreground">
Already have an account?{" "}
<Link
href="/auth/login"
className="font-semibold text-primary hover:text-primary/80 hover:underline transition-colors"
>
Sign in
</Link>{" "}
to access your dashboard and support tickets.
</p>
</div>
</div>
);
}

View File

@ -5,34 +5,24 @@ import { HelpCircle, MessageSquare, Mail, ChevronRight } from "lucide-react";
const FAQ_ITEMS = [
{
question: "How do I get started with your services?",
question: "Sample Question 1?",
answer:
"Simply browse our services, select a plan that fits your needs, and complete the checkout process. You can create an account during checkout.",
"This is a sample answer for frequently asked question 1. Replace this with actual content when available.",
},
{
question: "What payment methods do you accept?",
question: "Sample Question 2?",
answer:
"We accept major credit cards (Visa, Mastercard, American Express) and bank transfers. Payment methods can be managed in your account settings.",
"This is a sample answer for frequently asked question 2. Replace this with actual content when available.",
},
{
question: "How long does installation take?",
question: "Sample Question 3?",
answer:
"Internet installation typically takes 2-4 weeks depending on your location and the type of installation required. SIM cards are shipped within 3-5 business days.",
"This is a sample answer for frequently asked question 3. Replace this with actual content when available.",
},
{
question: "Can I change my plan after signing up?",
question: "Sample Question 4?",
answer:
"Yes, you can upgrade or downgrade your plan at any time. Changes typically take effect at the start of your next billing cycle.",
},
{
question: "What is your cancellation policy?",
answer:
"Most services have a minimum contract period (typically 3 months). After this period, you can cancel with one month's notice.",
},
{
question: "Do you offer business plans?",
answer:
"Yes, we offer dedicated business plans with enhanced support, higher speeds, and custom solutions. Please contact us for more information.",
"This is a sample answer for frequently asked question 4. Replace this with actual content when available.",
},
];
@ -47,9 +37,10 @@ export function PublicSupportView() {
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary/10 rounded-2xl mb-2 text-primary">
<HelpCircle className="h-8 w-8" />
</div>
<h1 className="text-3xl font-bold text-foreground">How can we help?</h1>
<h1 className="text-3xl font-bold text-foreground">How Can We Help?</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Find answers to common questions or get in touch with our support team.
Questions about our services? Our English-speaking team is ready to assist. Find answers
below or reach out directly.
</p>
</div>

View File

@ -46,6 +46,7 @@ function buildCSP(nonce: string, isDev: boolean): string {
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https: http://localhost:* ws://localhost:*",
"frame-src 'self' https://www.google.com",
"frame-ancestors 'none'",
].join("; ");
}
@ -61,6 +62,7 @@ function buildCSP(nonce: string, isDev: boolean): string {
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https:",
"frame-src 'self' https://www.google.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",

View File

@ -618,6 +618,16 @@
outline-offset: var(--cp-focus-ring-offset);
}
/* ===== SCROLLBAR ===== */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* ===== ACCESSIBILITY: REDUCED MOTION ===== */
@media (prefers-reduced-motion: reduce) {
.cp-animate-in,

View File

@ -0,0 +1,271 @@
/**
* SIMInventoryImporter
* Invocable Apex class for importing Physical SIM inventory from CSV files.
* Used by Screen Flow to allow employees to bulk import physical SIMs.
*
* CSV Format Expected (matching ASI_N6_PASI_*.csv):
* Row,Phone_Number,PT_Number,OEM_ID,Batch_Date,,,,,
* 1,02000002470001,PT0220024700010,PASI,20251229,,,,
*
* Note: No header row expected. All imports are Physical SIM type.
*
* @author Customer Portal Team
* @version 1.1
*/
public with sharing class SIMInventoryImporter {
// Hardcoded values for Physical SIM imports
private static final String SIM_TYPE = 'Physical SIM';
private static final Boolean SKIP_HEADER = false;
/**
* Input wrapper for the invocable method
*/
public class ImportRequest {
@InvocableVariable(label='Content Document IDs' description='IDs from File Upload component' required=true)
public List<String> contentDocumentIds;
}
/**
* Output wrapper for the invocable method
*/
public class ImportResult {
@InvocableVariable(label='Success')
public Boolean success;
@InvocableVariable(label='Records Created')
public Integer recordsCreated;
@InvocableVariable(label='Records Failed')
public Integer recordsFailed;
@InvocableVariable(label='Error Messages')
public String errorMessages;
@InvocableVariable(label='Summary Message')
public String summaryMessage;
}
/**
* Main invocable method called by Flow
*/
@InvocableMethod(label='Import SIM Inventory from CSV'
description='Parses CSV content and creates SIM_Inventory__c records'
category='SIM Management')
public static List<ImportResult> importFromCSV(List<ImportRequest> requests) {
List<ImportResult> results = new List<ImportResult>();
for (ImportRequest request : requests) {
results.add(processCSV(request));
}
return results;
}
/**
* Process a single CSV import request
*/
private static ImportResult processCSV(ImportRequest request) {
ImportResult result = new ImportResult();
result.success = true;
result.recordsCreated = 0;
result.recordsFailed = 0;
result.errorMessages = '';
try {
// Get the first Content Document ID from the list
if (request.contentDocumentIds == null || request.contentDocumentIds.isEmpty()) {
result.success = false;
result.errorMessages = 'No file was uploaded. Please select a CSV file.';
result.summaryMessage = 'Import failed: No file uploaded';
return result;
}
String contentDocumentId = request.contentDocumentIds[0];
// Retrieve file content from ContentVersion
List<ContentVersion> cvList = [
SELECT VersionData
FROM ContentVersion
WHERE ContentDocumentId = :contentDocumentId
AND IsLatest = true
LIMIT 1
];
if (cvList.isEmpty()) {
result.success = false;
result.errorMessages = 'Could not find the uploaded file. Please try again.';
result.summaryMessage = 'Import failed: File not found';
return result;
}
String csvContent = cvList[0].VersionData.toString();
// Parse CSV content
List<String> lines = csvContent.split('\n');
List<SIM_Inventory__c> simsToInsert = new List<SIM_Inventory__c>();
List<String> errors = new List<String>();
// Start from first row (no header row in Physical SIM CSV files)
Integer startIndex = SKIP_HEADER ? 1 : 0;
// Collect existing phone numbers to check for duplicates
Set<String> existingPhoneNumbers = new Set<String>();
for (SIM_Inventory__c existing : [SELECT Phone_Number__c FROM SIM_Inventory__c WHERE Phone_Number__c != null]) {
existingPhoneNumbers.add(existing.Phone_Number__c);
}
Set<String> phoneNumbersInBatch = new Set<String>();
for (Integer i = startIndex; i < lines.size(); i++) {
String line = lines[i].trim();
// Skip empty lines
if (String.isBlank(line)) {
continue;
}
// Remove carriage return if present (Windows line endings)
line = line.replace('\r', '');
try {
// Parse CSV line
List<String> columns = parseCSVLine(line);
// Expected format: Row,Phone_Number,PT_Number,OEM_ID,Batch_Date,,,,,
if (columns.size() < 2) {
errors.add('Row ' + (i + 1) + ': Not enough columns (need at least phone number)');
result.recordsFailed++;
continue;
}
String phoneNumber = columns.size() > 1 ? columns[1].trim() : '';
String ptNumber = columns.size() > 2 ? columns[2].trim() : '';
String oemId = columns.size() > 3 ? columns[3].trim() : '';
String batchDateStr = columns.size() > 4 ? columns[4].trim() : '';
// Validate phone number
if (String.isBlank(phoneNumber)) {
errors.add('Row ' + (i + 1) + ': Phone number is empty');
result.recordsFailed++;
continue;
}
// Check for duplicates in database
if (existingPhoneNumbers.contains(phoneNumber)) {
errors.add('Row ' + (i + 1) + ': Phone number ' + phoneNumber + ' already exists in database');
result.recordsFailed++;
continue;
}
// Check for duplicates within the CSV
if (phoneNumbersInBatch.contains(phoneNumber)) {
errors.add('Row ' + (i + 1) + ': Duplicate phone number ' + phoneNumber + ' in CSV file');
result.recordsFailed++;
continue;
}
// Parse batch date (format: YYYYMMDD)
Date batchDate = null;
if (String.isNotBlank(batchDateStr) && batchDateStr.length() >= 8) {
try {
Integer year = Integer.valueOf(batchDateStr.substring(0, 4));
Integer month = Integer.valueOf(batchDateStr.substring(4, 6));
Integer day = Integer.valueOf(batchDateStr.substring(6, 8));
batchDate = Date.newInstance(year, month, day);
} catch (Exception e) {
// Leave as null if parsing fails - not critical
}
}
// Create SIM_Inventory__c record
SIM_Inventory__c sim = new SIM_Inventory__c();
sim.Phone_Number__c = phoneNumber;
sim.PT_Number__c = ptNumber;
sim.OEM_ID__c = oemId;
sim.Batch_Date__c = batchDate;
sim.Status__c = 'Available';
sim.SIM_Type__c = SIM_TYPE; // Always Physical SIM
sim.Name = phoneNumber; // Use phone number as name for easy identification
simsToInsert.add(sim);
phoneNumbersInBatch.add(phoneNumber);
} catch (Exception e) {
errors.add('Row ' + (i + 1) + ': ' + e.getMessage());
result.recordsFailed++;
}
}
// Insert records with partial success allowed
if (!simsToInsert.isEmpty()) {
Database.SaveResult[] saveResults = Database.insert(simsToInsert, false);
for (Integer i = 0; i < saveResults.size(); i++) {
if (saveResults[i].isSuccess()) {
result.recordsCreated++;
} else {
result.recordsFailed++;
for (Database.Error err : saveResults[i].getErrors()) {
errors.add('Insert error for ' + simsToInsert[i].Phone_Number__c + ': ' + err.getMessage());
}
}
}
}
// Build error message string (limit to first 10 errors for readability)
if (!errors.isEmpty()) {
if (errors.size() <= 10) {
result.errorMessages = String.join(errors, '\n');
} else {
List<String> firstTen = new List<String>();
for (Integer i = 0; i < 10; i++) {
firstTen.add(errors[i]);
}
result.errorMessages = String.join(firstTen, '\n')
+ '\n\n... and ' + (errors.size() - 10) + ' more errors';
}
}
// Build summary message
result.summaryMessage = 'Import completed: ' + result.recordsCreated + ' records created successfully.';
if (result.recordsFailed > 0) {
result.summaryMessage += ' ' + result.recordsFailed + ' records failed.';
result.success = (result.recordsCreated > 0); // Partial success if any records created
}
} catch (Exception e) {
result.success = false;
result.errorMessages = 'Critical error: ' + e.getMessage() + '\n\nStack trace: ' + e.getStackTraceString();
result.summaryMessage = 'Import failed due to an unexpected error.';
}
return result;
}
/**
* Parse a single CSV line, handling quoted fields properly
*/
private static List<String> parseCSVLine(String line) {
List<String> result = new List<String>();
Boolean inQuotes = false;
String currentField = '';
for (Integer i = 0; i < line.length(); i++) {
String c = line.substring(i, i + 1);
if (c == '"') {
inQuotes = !inQuotes;
} else if (c == ',' && !inQuotes) {
result.add(currentField);
currentField = '';
} else {
currentField += c;
}
}
// Add the last field
result.add(currentField);
return result;
}
}

View File

@ -0,0 +1,209 @@
/**
* Test class for SIMInventoryImporter
* Provides code coverage for deployment to production.
*
* @author Customer Portal Team
* @version 1.1
*/
@isTest
private class SIMInventoryImporterTest {
/**
* Helper method to create a ContentVersion (file) for testing
*/
private static String createTestFile(String csvContent) {
ContentVersion cv = new ContentVersion();
cv.Title = 'Test SIM Import';
cv.PathOnClient = 'test_sims.csv';
cv.VersionData = Blob.valueOf(csvContent);
insert cv;
// Get the ContentDocumentId
cv = [SELECT ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id];
return cv.ContentDocumentId;
}
@isTest
static void testSuccessfulImport() {
// Prepare test CSV content (matches ASI_N6_PASI format - no header row)
String csvContent = '1,02000002470001,PT0220024700010,PASI,20251229,,,,\n' +
'2,02000002470002,PT0220024700020,PASI,20251229,,,,\n' +
'3,02000002470003,PT0220024700030,PASI,20251229,,,,';
String contentDocId = createTestFile(csvContent);
SIMInventoryImporter.ImportRequest request = new SIMInventoryImporter.ImportRequest();
request.contentDocumentIds = new List<String>{ contentDocId };
Test.startTest();
List<SIMInventoryImporter.ImportResult> results =
SIMInventoryImporter.importFromCSV(new List<SIMInventoryImporter.ImportRequest>{ request });
Test.stopTest();
System.assertEquals(1, results.size(), 'Should return one result');
System.assertEquals(true, results[0].success, 'Import should succeed');
System.assertEquals(3, results[0].recordsCreated, 'Should create 3 records');
System.assertEquals(0, results[0].recordsFailed, 'Should have no failures');
// Verify records were created correctly
List<SIM_Inventory__c> sims = [
SELECT Phone_Number__c, PT_Number__c, OEM_ID__c, Status__c, SIM_Type__c, Batch_Date__c
FROM SIM_Inventory__c
ORDER BY Phone_Number__c
];
System.assertEquals(3, sims.size(), 'Should have 3 SIM records');
System.assertEquals('02000002470001', sims[0].Phone_Number__c);
System.assertEquals('PT0220024700010', sims[0].PT_Number__c);
System.assertEquals('PASI', sims[0].OEM_ID__c);
System.assertEquals('Available', sims[0].Status__c);
System.assertEquals('Physical SIM', sims[0].SIM_Type__c);
System.assertEquals(Date.newInstance(2025, 12, 29), sims[0].Batch_Date__c);
}
@isTest
static void testDuplicateDetectionInDatabase() {
// Create existing record
insert new SIM_Inventory__c(
Name = '02000002470001',
Phone_Number__c = '02000002470001',
PT_Number__c = 'PT0220024700010',
Status__c = 'Available',
SIM_Type__c = 'Physical SIM'
);
// Try to import same phone number
String csvContent = '1,02000002470001,PT0220024700010,PASI,20251229,,,,';
String contentDocId = createTestFile(csvContent);
SIMInventoryImporter.ImportRequest request = new SIMInventoryImporter.ImportRequest();
request.contentDocumentIds = new List<String>{ contentDocId };
Test.startTest();
List<SIMInventoryImporter.ImportResult> results =
SIMInventoryImporter.importFromCSV(new List<SIMInventoryImporter.ImportRequest>{ request });
Test.stopTest();
System.assertEquals(0, results[0].recordsCreated, 'Should not create duplicate');
System.assertEquals(1, results[0].recordsFailed, 'Should report 1 failure');
System.assert(results[0].errorMessages.contains('already exists'), 'Should mention duplicate');
}
@isTest
static void testDuplicateDetectionInCSV() {
// CSV with duplicate phone numbers
String csvContent = '1,02000002470001,PT0220024700010,PASI,20251229,,,,\n' +
'2,02000002470001,PT0220024700010,PASI,20251229,,,,';
String contentDocId = createTestFile(csvContent);
SIMInventoryImporter.ImportRequest request = new SIMInventoryImporter.ImportRequest();
request.contentDocumentIds = new List<String>{ contentDocId };
Test.startTest();
List<SIMInventoryImporter.ImportResult> results =
SIMInventoryImporter.importFromCSV(new List<SIMInventoryImporter.ImportRequest>{ request });
Test.stopTest();
System.assertEquals(1, results[0].recordsCreated, 'Should create first record only');
System.assertEquals(1, results[0].recordsFailed, 'Should fail on duplicate');
}
@isTest
static void testEmptyPhoneNumber() {
String csvContent = '1,,PT0220024700010,PASI,20251229,,,,';
String contentDocId = createTestFile(csvContent);
SIMInventoryImporter.ImportRequest request = new SIMInventoryImporter.ImportRequest();
request.contentDocumentIds = new List<String>{ contentDocId };
Test.startTest();
List<SIMInventoryImporter.ImportResult> results =
SIMInventoryImporter.importFromCSV(new List<SIMInventoryImporter.ImportRequest>{ request });
Test.stopTest();
System.assertEquals(0, results[0].recordsCreated, 'Should not create record without phone');
System.assertEquals(1, results[0].recordsFailed, 'Should report failure');
}
@isTest
static void testEmptyFile() {
String csvContent = '';
String contentDocId = createTestFile(csvContent);
SIMInventoryImporter.ImportRequest request = new SIMInventoryImporter.ImportRequest();
request.contentDocumentIds = new List<String>{ contentDocId };
Test.startTest();
List<SIMInventoryImporter.ImportResult> results =
SIMInventoryImporter.importFromCSV(new List<SIMInventoryImporter.ImportRequest>{ request });
Test.stopTest();
System.assertEquals(0, results[0].recordsCreated, 'Should create no records');
System.assertEquals(0, results[0].recordsFailed, 'Should have no failures for empty file');
}
@isTest
static void testAlwaysPhysicalSIM() {
// Verify that all imported SIMs are set to Physical SIM type
String csvContent = '1,02000002470001,PT0220024700010,PASI,20251229,,,,';
String contentDocId = createTestFile(csvContent);
SIMInventoryImporter.ImportRequest request = new SIMInventoryImporter.ImportRequest();
request.contentDocumentIds = new List<String>{ contentDocId };
Test.startTest();
List<SIMInventoryImporter.ImportResult> results =
SIMInventoryImporter.importFromCSV(new List<SIMInventoryImporter.ImportRequest>{ request });
Test.stopTest();
SIM_Inventory__c sim = [SELECT SIM_Type__c FROM SIM_Inventory__c LIMIT 1];
System.assertEquals('Physical SIM', sim.SIM_Type__c, 'Should always be Physical SIM');
}
@isTest
static void testLargeImport() {
// Test with 50 records (matches real CSV file size)
String csvContent = '';
for (Integer i = 1; i <= 50; i++) {
String phoneNum = '0200000247' + String.valueOf(i).leftPad(4, '0');
String ptNum = 'PT022002470' + String.valueOf(i).leftPad(4, '0') + '0';
csvContent += i + ',' + phoneNum + ',' + ptNum + ',PASI,20251229,,,,\n';
}
String contentDocId = createTestFile(csvContent);
SIMInventoryImporter.ImportRequest request = new SIMInventoryImporter.ImportRequest();
request.contentDocumentIds = new List<String>{ contentDocId };
Test.startTest();
List<SIMInventoryImporter.ImportResult> results =
SIMInventoryImporter.importFromCSV(new List<SIMInventoryImporter.ImportRequest>{ request });
Test.stopTest();
System.assertEquals(50, results[0].recordsCreated, 'Should create all 50 records');
System.assertEquals(0, results[0].recordsFailed, 'Should have no failures');
Integer count = [SELECT COUNT() FROM SIM_Inventory__c];
System.assertEquals(50, count, 'Database should have 50 records');
}
@isTest
static void testInvalidDateFormat() {
// Invalid date format should not fail the import, just leave date null
String csvContent = '1,02000002470001,PT0220024700010,PASI,invalid_date,,,,';
String contentDocId = createTestFile(csvContent);
SIMInventoryImporter.ImportRequest request = new SIMInventoryImporter.ImportRequest();
request.contentDocumentIds = new List<String>{ contentDocId };
Test.startTest();
List<SIMInventoryImporter.ImportResult> results =
SIMInventoryImporter.importFromCSV(new List<SIMInventoryImporter.ImportRequest>{ request });
Test.stopTest();
System.assertEquals(1, results[0].recordsCreated, 'Should still create record');
SIM_Inventory__c sim = [SELECT Batch_Date__c FROM SIM_Inventory__c LIMIT 1];
System.assertEquals(null, sim.Batch_Date__c, 'Date should be null for invalid format');
}
}

View File

@ -0,0 +1,256 @@
# SIM Inventory CSV Import - Screen Flow Setup
This guide provides the Apex class and Screen Flow configuration to enable employees to import Physical SIM data via CSV file upload.
**Simplified for Physical SIM imports only - no header row expected.**
---
## Overview
The solution consists of:
1. **Apex Invocable Class** - Parses CSV and creates SIM_Inventory\_\_c records
2. **Screen Flow** - Simple UI with just file upload and results display
---
## Step 1: Deploy the Apex Classes
Copy the Apex classes from:
- `docs/integrations/salesforce/apex/SIMInventoryImporter.cls`
- `docs/integrations/salesforce/apex/SIMInventoryImporterTest.cls`
### Deploy Steps:
1. Go to **Setup → Apex Classes → New**
2. Paste the content of `SIMInventoryImporter.cls` → Save
3. Create another class, paste `SIMInventoryImporterTest.cls` → Save
4. Run tests to verify (Setup → Apex Test Execution)
---
## Step 2: Create the Screen Flow
### Flow Configuration
1. Go to **Setup → Flows → New Flow**
2. Select **Screen Flow**
3. Click **Create**
### Flow Elements
#### Element 1: Screen - File Upload
**Screen Properties:**
- Label: `Upload Physical SIM CSV`
- API Name: `Upload_SIM_CSV`
**Components on Screen:**
1. **Display Text** (Header)
- API Name: `Header_Text`
- Content:
```
# Import Physical SIM Inventory
Upload a CSV file containing Physical SIM data.
**Expected format (no header row):**
`Row,Phone_Number,PT_Number,OEM_ID,Batch_Date,,,,,`
**Example:**
`1,02000002470001,PT0220024700010,PASI,20251229,,,,`
```
2. **File Upload**
- API Name: `CSV_File_Upload`
- Label: `Select CSV File`
- Allow Multiple Files: `No`
- Accept: `.csv`
- Required: `Yes`
#### Element 2: Action - Call Apex Importer
**Action Properties:**
- Category: `Apex`
- Action: `Import SIM Inventory from CSV`
**Input Values:**
- `contentDocumentId``{!CSV_File_Upload}` (the file upload component returns the ContentDocumentId)
**Store Output:**
- Create variables to store the output:
- `ImportResult_Success` (Boolean)
- `ImportResult_RecordsCreated` (Number)
- `ImportResult_RecordsFailed` (Number)
- `ImportResult_ErrorMessages` (Text)
- `ImportResult_SummaryMessage` (Text)
#### Element 3: Screen - Results
**Screen Properties:**
- Label: `Import Results`
- API Name: `Import_Results`
**Components:**
1. **Display Text** (Success Message)
- API Name: `Success_Message`
- Visibility: Show when `{!ImportResult_Success} Equals true`
- Content:
```
✅ **Import Successful**
**Records Created:** {!ImportResult_RecordsCreated}
{!ImportResult_SummaryMessage}
```
2. **Display Text** (Error Details)
- API Name: `Error_Details`
- Visibility: Show when `{!ImportResult_RecordsFailed} Greater than 0`
- Content:
```
⚠️ **Some records had issues:**
{!ImportResult_ErrorMessages}
```
3. **Display Text** (Failure Message)
- API Name: `Failure_Message`
- Visibility: Show when `{!ImportResult_Success} Equals false`
- Content:
```
❌ **Import Failed**
{!ImportResult_ErrorMessages}
```
---
## Step 3: Flow Diagram (Simplified)
```
┌─────────────────────────┐
│ Start │
└───────────┬─────────────┘
┌─────────────────────────┐
│ Screen: Upload CSV │
│ - File Upload only │
└───────────┬─────────────┘
┌─────────────────────────┐
│ Action: Import SIM │
│ Inventory from CSV │
│ (Apex Invocable) │
└───────────┬─────────────┘
┌─────────────────────────┐
│ Screen: Import Results │
│ - Success/Fail Message │
│ - Records Created │
│ - Error Details │
└───────────┬─────────────┘
┌─────────────────────────┐
│ End │
└─────────────────────────┘
```
---
## Step 4: Add Flow to Lightning App
1. Go to **Setup → App Manager**
2. Edit your app (e.g., "Sales" or custom app)
3. Add the Flow to utility items or create a Tab
4. Alternatively, embed in a Lightning Page:
- Edit any Lightning Record Page
- Add "Flow" component
- Select your "Import SIM Inventory" flow
---
## Alternative: Quick Action Button
Create a Quick Action to launch the flow from the SIM Inventory list view:
1. **Setup → Object Manager → SIM Inventory → Buttons, Links, and Actions**
2. Click **New Action**
3. Action Type: `Flow`
4. Flow: Select your import flow
5. Label: `Import SIMs from CSV`
6. Add to Page Layout
---
## CSV File Format Reference
Your CSV files should follow this format:
| Column | Field | Example | Required |
| ------ | ------------ | --------------- | ------------ |
| 1 | Row Number | 1 | No (ignored) |
| 2 | Phone Number | 02000002470001 | Yes |
| 3 | PT Number | PT0220024700010 | No |
| 4 | OEM ID | PASI | No |
| 5 | Batch Date | 20251229 | No |
| 6-9 | Empty | | No |
**Example CSV:**
```csv
1,02000002470001,PT0220024700010,PASI,20251229,,,,
2,02000002470002,PT0220024700020,PASI,20251229,,,,
3,02000002470003,PT0220024700030,PASI,20251229,,,,
```
---
## Troubleshooting
### Common Issues
1. **"Not enough columns" error**
- Ensure CSV has at least 5 columns (even if some are empty)
- Check for proper comma separators
2. **"Phone number already exists" error**
- The phone number is already in SIM_Inventory\_\_c
- Check existing records before importing
3. **File upload not working**
- Ensure file is .csv format
- Check file size (Salesforce limit: 25MB for files)
4. **Permission errors**
- User needs Create permission on SIM_Inventory\_\_c
- User needs access to the Flow
---
## Security Considerations
- The Apex class uses `with sharing` to respect record-level security
- Only users with appropriate permissions can run the Flow
- Consider adding a Permission Set for SIM Inventory management
---
**Last Updated:** January 2025

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

View File

@ -95,11 +95,11 @@ export const defaultSalesforceOrderFieldMap: SalesforceOrderFieldMap = {
mnpExpiry: "MNP_Expiry_Date__c",
mnpPhone: "MNP_Phone_Number__c",
mvnoAccountNumber: "MVNO_Account_Number__c",
portingDateOfBirth: "Porting_Date_Of_Birth__c",
portingFirstName: "Porting_First_Name__c",
portingLastName: "Porting_Last_Name__c",
portingFirstNameKatakana: "Porting_First_Name_Katakana__c",
portingLastNameKatakana: "Porting_Last_Name_Katakana__c",
portingDateOfBirth: "Porting_DateOfBirth__c",
portingFirstName: "Porting_FirstName__c",
portingLastName: "Porting_LastName__c",
portingFirstNameKatakana: "Porting_FirstName_Katakana__c",
portingLastNameKatakana: "Porting_LastName_Katakana__c",
portingGender: "Porting_Gender__c",
},
orderItem: {

View File

@ -69,6 +69,7 @@ export const salesforceOrderRecordSchema = z.object({
SIM_Voice_Mail__c: z.boolean().nullable().optional(),
SIM_Call_Waiting__c: z.boolean().nullable().optional(),
EID__c: z.string().nullable().optional(),
Assign_Physical_SIM__c: z.string().nullable().optional(), // Lookup to SIM_Inventory__c
// MNP (Mobile Number Portability) fields
MNP_Application__c: z.string().nullable().optional(),
@ -76,11 +77,11 @@ export const salesforceOrderRecordSchema = z.object({
MNP_Expiry_Date__c: z.string().nullable().optional(),
MNP_Phone_Number__c: z.string().nullable().optional(),
MVNO_Account_Number__c: z.string().nullable().optional(),
Porting_Date_Of_Birth__c: z.string().nullable().optional(),
Porting_First_Name__c: z.string().nullable().optional(),
Porting_Last_Name__c: z.string().nullable().optional(),
Porting_First_Name_Katakana__c: z.string().nullable().optional(),
Porting_Last_Name_Katakana__c: z.string().nullable().optional(),
Porting_DateOfBirth__c: z.string().nullable().optional(),
Porting_FirstName__c: z.string().nullable().optional(),
Porting_LastName__c: z.string().nullable().optional(),
Porting_FirstName_Katakana__c: z.string().nullable().optional(),
Porting_LastName_Katakana__c: z.string().nullable().optional(),
Porting_Gender__c: z.string().nullable().optional(),
// Billing address snapshot fields (standard Salesforce Order columns)

View File

@ -112,6 +112,7 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
const pids: string[] = [];
const billingCycles: string[] = [];
const quantities: number[] = [];
const domains: string[] = [];
const configOptions: string[] = [];
const customFields: string[] = [];
@ -119,6 +120,7 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
pids.push(item.productId);
billingCycles.push(item.billingCycle);
quantities.push(item.quantity);
domains.push(item.domain || ""); // Domain/hostname (phone number for SIM)
// Handle config options - WHMCS expects base64 encoded serialized arrays
configOptions.push(serializeWhmcsKeyValueMap(item.configOptions));
@ -146,6 +148,11 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
qty: quantities,
};
// Add domain array if any items have domains
if (domains.some(d => d !== "")) {
payload.domain = domains;
}
// Add optional fields
if (params.promoCode) {
payload.promocode = params.promoCode;

View File

@ -38,6 +38,7 @@ export const whmcsOrderItemSchema = z.object({
"free",
]),
quantity: z.number().int().positive("Quantity must be positive").default(1),
domain: z.string().optional(), // Domain/hostname field for the service (phone number for SIM)
configOptions: z.record(z.string(), z.string()).optional(),
customFields: z.record(z.string(), z.string()).optional(),
});
@ -78,6 +79,7 @@ export const whmcsAddOrderPayloadSchema = z.object({
pid: z.array(z.string()).min(1),
billingcycle: z.array(z.string()).min(1),
qty: z.array(z.number().int().positive()).min(1),
domain: z.array(z.string()).optional(), // Domain/hostname for each product (phone number for SIM)
configoptions: z.array(z.string()).optional(), // base64 encoded
customfields: z.array(z.string()).optional(), // base64 encoded
});

View File

@ -82,3 +82,52 @@ export function buildSimFeaturesUpdatePayload(
return Object.keys(payload).length > 0 ? payload : null;
}
/**
* Plan code mapping from product SKU/name to Freebit plan code
* Maps common data tiers: 5GB, 10GB, 25GB, 50GB
*/
const PLAN_CODE_MAPPING: Record<string, string> = {
"5": "PASI_5G",
"10": "PASI_10G",
"25": "PASI_25G",
"50": "PASI_50G",
};
/**
* Maps a product SKU or name to the corresponding Freebit plan code.
*
* Extracts the data tier (e.g., "50" from "SIM Data+Voice 50GB" or "sim-50gb")
* and maps it to the appropriate PASI plan code.
*
* @param productSku - The product SKU (e.g., "sim-data-voice-50gb")
* @param productName - The product name (e.g., "SIM Data+Voice 50GB")
* @returns The Freebit plan code (e.g., "PASI_50G") or null if not mappable
*/
export function mapProductToFreebitPlanCode(
productSku?: string,
productName?: string
): string | null {
// Try to extract data tier from SKU or name
const source = productSku || productName || "";
// Match patterns like "50GB", "50G", "50gb", or just "50" in context of GB
const gbMatch = source.match(/(\d+)\s*G(?:B)?/i);
if (gbMatch?.[1]) {
const tier = gbMatch[1];
if (tier in PLAN_CODE_MAPPING) {
return PLAN_CODE_MAPPING[tier];
}
}
// Try matching standalone numbers in SKU patterns like "sim-50gb"
const skuMatch = source.match(/[-_](\d+)[-_]?(?:gb|g)?/i);
if (skuMatch?.[1]) {
const tier = skuMatch[1];
if (tier in PLAN_CODE_MAPPING) {
return PLAN_CODE_MAPPING[tier];
}
}
return null;
}

View File

@ -26,6 +26,7 @@ export {
SIM_PLAN_OPTIONS,
getSimPlanLabel,
buildSimFeaturesUpdatePayload,
mapProductToFreebitPlanCode,
} from "./helpers.js";
export type { SimPlanCode } from "./contract.js";
export type { SimPlanOption, SimFeatureToggleSnapshot } from "./helpers.js";

View File

@ -22,6 +22,8 @@ export const schemas = {
esimAddAccount: Requests.freebitEsimAddAccountRequestSchema,
auth: Requests.freebitAuthRequestSchema,
cancelAccount: Requests.freebitCancelAccountRequestSchema,
otaActivation: Requests.freebitOtaActivationRequestSchema,
otaActivationResponse: Requests.freebitOtaActivationResponseSchema,
};
export const raw = RawTypes;
@ -41,6 +43,8 @@ export type CancelPlanRequest = Requests.FreebitCancelPlanRequest;
export type CancelPlanApiRequest = Requests.FreebitCancelPlanApiRequest;
export type CancelAccountRequest = Requests.FreebitCancelAccountRequest;
export type AuthRequest = Requests.FreebitAuthRequest;
export type OtaActivationRequest = Requests.FreebitOtaActivationRequest;
export type OtaActivationResponse = Requests.FreebitOtaActivationResponse;
export type TopUpResponse = ReturnType<typeof Mapper.transformFreebitTopUpResponse>;
export type AddSpecResponse = ReturnType<typeof Mapper.transformFreebitAddSpecResponse>;
export type PlanChangeResponse = ReturnType<typeof Mapper.transformFreebitPlanChangeResponse>;

View File

@ -287,3 +287,61 @@ export type FreebitQuotaHistoryResponse = z.infer<typeof freebitQuotaHistoryResp
export type FreebitEsimAddAccountRequest = z.infer<typeof freebitEsimAddAccountRequestSchema>;
export type FreebitAuthRequest = z.infer<typeof freebitAuthRequestSchema>;
export type FreebitCancelAccountRequest = z.infer<typeof freebitCancelAccountRequestSchema>;
// ============================================================================
// Physical SIM OTA Activation (PA05-33)
// ============================================================================
/**
* Freebit OTA Account Activation Request Schema
* PA05-33 API endpoint: /mvno/ota/addAcnt/
* Used for Physical SIM activation via OTA (Over-The-Air)
*/
export const freebitOtaActivationRequestSchema = z.object({
authKey: z.string().min(1, "Auth key is required"),
aladinOperated: z.enum(["10", "20"]).default("10"), // 10: issue profile, 20: no-issue
createType: z.enum(["new", "reissue"]).default("new"),
account: z.string().min(1, "Account (MSISDN) is required"),
productNumber: z.string().min(1, "Product number (PT) is required"),
simkind: z.string().optional(), // Physical SIM kind (e.g., '3MS', '3MR')
planCode: z.string().optional(),
contractLine: z.enum(["4G", "5G"]).optional(),
size: z.enum(["standard", "micro", "nano"]).default("nano"),
shipDate: z
.string()
.regex(/^\d{8}$/, "Ship date must be in YYYYMMDD format")
.optional(),
deliveryCode: z.string().optional(), // OEM ID code
addKind: z.enum(["N", "M"]).default("N"), // N: New, M: MNP
mnp: z
.object({
reserveNumber: z.string().min(1, "MNP reserve number is required"),
reserveExpireDate: z.string().regex(/^\d{8}$/, "Reserve expire date must be YYYYMMDD"),
account: z.string().optional(),
firstnameKanji: z.string().optional(),
lastnameKanji: z.string().optional(),
firstnameZenKana: z.string().optional(),
lastnameZenKana: z.string().optional(),
gender: z.enum(["M", "F"]).optional(),
birthday: z.string().optional(),
})
.optional(),
});
/**
* Freebit OTA Account Activation Response Schema
*/
export const freebitOtaActivationResponseSchema = z.object({
resultCode: z.string(),
resultMessage: z.string().optional(),
status: z
.object({
statusCode: z.union([z.string(), z.number()]),
message: z.string(),
})
.optional(),
message: z.string().optional(),
});
export type FreebitOtaActivationRequest = z.infer<typeof freebitOtaActivationRequestSchema>;
export type FreebitOtaActivationResponse = z.infer<typeof freebitOtaActivationResponseSchema>;