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)
85
apps/bff/scripts/check-sim-status.mjs
Normal 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();
|
||||
@ -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
|
||||
|
||||
90
apps/bff/sim-api-test-log.csv
Normal 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
|
||||
|
@ -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 {}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.";
|
||||
}
|
||||
|
||||
@ -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")),
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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=
|
||||
BIN
apps/portal/public/assets/images/About us.png
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
apps/portal/public/assets/images/Hero Image.png
Normal file
|
After Width: | Height: | Size: 311 KiB |
BIN
apps/portal/public/assets/images/Why_us.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
@ -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 |
BIN
apps/portal/public/assets/images/arconis.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
4
apps/portal/public/assets/images/teamviewer-qs.svg
Normal 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 |
BIN
apps/portal/public/assets/images/teamviewer.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@ -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 />;
|
||||
}
|
||||
|
||||
23
apps/portal/src/app/(public)/(site)/blog/layout.tsx
Normal 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;
|
||||
}
|
||||
200
apps/portal/src/app/(public)/(site)/blog/page.tsx
Normal 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">& 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>
|
||||
);
|
||||
}
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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'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'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've been
|
||||
helping foreign companies navigate Japanese IT for over 20 years. Let's discuss how
|
||||
we can support your operations.
|
||||
</p>
|
||||
<Button as="a" href="/contact" size="lg">
|
||||
Contact Us
|
||||
Get in Touch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -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 />
|
||||
|
||||
22
apps/portal/src/app/robots.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
33
apps/portal/src/app/sitemap.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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'll check what's available
|
||||
at your address.
|
||||
Good news: All types have the same monthly price (¥4,800~). We'll check what'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>
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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'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;
|
||||
|
||||
@ -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'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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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'",
|
||||
|
||||
@ -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,
|
||||
|
||||
271
docs/integrations/salesforce/apex/SIMInventoryImporter.cls
Normal 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;
|
||||
}
|
||||
}
|
||||
209
docs/integrations/salesforce/apex/SIMInventoryImporterTest.cls
Normal 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');
|
||||
}
|
||||
}
|
||||
256
docs/integrations/salesforce/sim-inventory-import.md
Normal 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
|
||||
87
docs/testing/sim-api-test-tracking.md
Normal 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
|
||||
```
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>;
|
||||
|
||||