Simplify physical SIM activation and enhance order fulfillment
- Remove PA05-18 semi-black step from physical SIM flow, use PA02-01 directly
- Add WHMCS client ID fallback from Salesforce WH_Account__c field
- Return service IDs from WHMCS AcceptOrder for proper linking
- Add phone number to WHMCS domain field and WHMCS admin URL to Salesforce
- Change SIM Inventory status from "In Use" to "Assigned"
- Fix SIM services query case sensitivity ("SIM" → "Sim")
- Add bash 3.2 compatibility to dev-watch.sh
- Add "Most Popular Services" section to landing page
- Add "Trusted By" company carousel to About page
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9fbb6ed61e
commit
35619f24d1
85
apps/bff/scripts/check-sim-status.mjs
Normal file
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
|
||||
|
||||
@ -84,3 +84,7 @@ Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Re
|
||||
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
|
||||
|
||||
|
@ -21,6 +21,8 @@ export interface AccountRegistrationParams {
|
||||
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) */
|
||||
@ -78,7 +80,7 @@ export class FreebitAccountRegistrationService {
|
||||
* @throws BadRequestException if registration fails
|
||||
*/
|
||||
async registerAccount(params: AccountRegistrationParams): Promise<void> {
|
||||
const { account, planCode, globalIp, priorityFlag } = params;
|
||||
const { account, planCode, createType = "new", globalIp, priorityFlag } = params;
|
||||
|
||||
// Validate required parameters
|
||||
if (!account || account.length < 11 || account.length > 14) {
|
||||
@ -95,6 +97,7 @@ export class FreebitAccountRegistrationService {
|
||||
account,
|
||||
accountLength: account.length,
|
||||
planCode,
|
||||
createType,
|
||||
hasGlobalIp: !!globalIp,
|
||||
hasPriorityFlag: !!priorityFlag,
|
||||
});
|
||||
@ -103,7 +106,7 @@ export class FreebitAccountRegistrationService {
|
||||
// Build payload according to PA02-01 documentation
|
||||
// Note: authKey is added automatically by makeAuthenticatedRequest
|
||||
const payload: FreebitAccountRegistrationRequest = {
|
||||
createType: "new",
|
||||
createType,
|
||||
requestDatas: [
|
||||
{
|
||||
kind: "MVNO",
|
||||
|
||||
@ -140,30 +140,12 @@ export class FreebitSemiBlackService {
|
||||
...(deliveryCode && { deliveryCode }),
|
||||
};
|
||||
|
||||
const response = await this.client.makeAuthenticatedRequest<
|
||||
// FreebitClientService validates resultCode === "100" before returning
|
||||
await this.client.makeAuthenticatedRequest<
|
||||
FreebitSemiBlackResponse,
|
||||
Omit<FreebitSemiBlackRequest, "authKey">
|
||||
>("/mvno/semiblack/addAcnt/", payload);
|
||||
|
||||
// Check response status
|
||||
if (response.resultCode !== 100 || response.status?.statusCode !== 200) {
|
||||
const errorCode = response.resultCode;
|
||||
const errorMessage = this.getErrorMessage(errorCode, response.status?.message);
|
||||
|
||||
this.logger.error("Semi-black registration failed (PA05-18)", {
|
||||
account,
|
||||
productNumber,
|
||||
planCode,
|
||||
resultCode: response.resultCode,
|
||||
statusCode: response.status?.statusCode,
|
||||
message: response.status?.message,
|
||||
});
|
||||
|
||||
throw new BadRequestException(
|
||||
`Semi-black registration failed: ${errorMessage} (code: ${errorCode})`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log("Semi-black SIM registration successful (PA05-18)", {
|
||||
account,
|
||||
productNumber,
|
||||
|
||||
@ -118,6 +118,9 @@ export const OPPORTUNITY_PORTAL_INTEGRATION_FIELDS = {
|
||||
/** WHMCS Service ID (populated after provisioning) */
|
||||
whmcsServiceId: "WHMCS_Service_ID__c",
|
||||
|
||||
/** WHMCS Registration URL (link to service in WHMCS admin) */
|
||||
whmcsRegistrationUrl: "WH_Registeration__c",
|
||||
|
||||
// NOTE: Cancellation comments and alternative email go on the Cancellation Case,
|
||||
// not on the Opportunity. This keeps Opportunity clean and Case contains all details.
|
||||
} as const;
|
||||
|
||||
@ -828,16 +828,19 @@ export class SalesforceOpportunityService {
|
||||
*
|
||||
* @param opportunityId - Salesforce Opportunity ID
|
||||
* @param whmcsServiceId - WHMCS Service/Hosting ID
|
||||
* @param whmcsClientId - Optional WHMCS Client ID for building admin URL
|
||||
*/
|
||||
async linkWhmcsServiceToOpportunity(
|
||||
opportunityId: string,
|
||||
whmcsServiceId: number
|
||||
whmcsServiceId: number,
|
||||
whmcsClientId?: number
|
||||
): Promise<void> {
|
||||
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
|
||||
|
||||
this.logger.log("Linking WHMCS Service to Opportunity", {
|
||||
opportunityId: safeOppId,
|
||||
whmcsServiceId,
|
||||
whmcsClientId,
|
||||
});
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
@ -845,6 +848,12 @@ export class SalesforceOpportunityService {
|
||||
[OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId,
|
||||
};
|
||||
|
||||
// Build WHMCS admin URL if client ID is provided
|
||||
if (whmcsClientId) {
|
||||
const whmcsAdminUrl = `https://dev-wh.asolutions.co.jp/admin/clientsservices.php?userid=${whmcsClientId}&productselect=${whmcsServiceId}`;
|
||||
payload[OPPORTUNITY_FIELD_MAP.whmcsRegistrationUrl] = whmcsAdminUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const updateMethod = this.sf.sobject("Opportunity").update;
|
||||
if (!updateMethod) {
|
||||
@ -856,6 +865,7 @@ export class SalesforceOpportunityService {
|
||||
this.logger.log("WHMCS Service linked to Opportunity", {
|
||||
opportunityId: safeOppId,
|
||||
whmcsServiceId,
|
||||
hasRegistrationUrl: !!whmcsClientId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to link WHMCS Service to Opportunity", {
|
||||
|
||||
@ -21,7 +21,7 @@ import { SimActivationException } from "@bff/core/exceptions/domain-exceptions.j
|
||||
*/
|
||||
export const SIM_INVENTORY_STATUS = {
|
||||
AVAILABLE: "Available",
|
||||
IN_USE: "In Use",
|
||||
ASSIGNED: "Assigned",
|
||||
RESERVED: "Reserved",
|
||||
DEACTIVATED: "Deactivated",
|
||||
} as const;
|
||||
@ -164,20 +164,20 @@ export class SalesforceSIMInventoryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SIM Inventory status to "In Use" after successful activation
|
||||
* Update SIM Inventory status to "Assigned" after successful activation
|
||||
*/
|
||||
async markAsInUse(simInventoryId: string): Promise<void> {
|
||||
async markAsAssigned(simInventoryId: string): Promise<void> {
|
||||
const safeId = assertSalesforceId(simInventoryId, "simInventoryId");
|
||||
|
||||
this.logger.log("Marking SIM Inventory as In Use", { simInventoryId: safeId });
|
||||
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.IN_USE,
|
||||
Status__c: SIM_INVENTORY_STATUS.ASSIGNED,
|
||||
});
|
||||
|
||||
this.logger.log("SIM Inventory marked as In Use", { simInventoryId: safeId });
|
||||
this.logger.log("SIM Inventory marked as Assigned", { simInventoryId: safeId });
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to update SIM Inventory status", {
|
||||
simInventoryId: safeId,
|
||||
|
||||
@ -117,8 +117,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,
|
||||
@ -150,11 +155,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", {
|
||||
|
||||
@ -317,9 +317,12 @@ export class OrderFulfillmentOrchestrator {
|
||||
// Use domain mapper directly - single transformation!
|
||||
const result = mapOrderToWhmcsItems(context.orderDetails);
|
||||
|
||||
// Add SIM custom fields if we have SIM data
|
||||
// Add SIM data if we have it (phone number goes to domain field and custom fields)
|
||||
if (simFulfillmentResult?.activated && simFulfillmentResult.phoneNumber) {
|
||||
result.whmcsItems.forEach(item => {
|
||||
// Set phone number as domain (shows in WHMCS Domain field)
|
||||
item.domain = simFulfillmentResult!.phoneNumber!;
|
||||
// Also add to custom fields for SIM Number field
|
||||
item.customFields = {
|
||||
...item.customFields,
|
||||
SimNumber: simFulfillmentResult!.phoneNumber!,
|
||||
@ -414,8 +417,18 @@ export class OrderFulfillmentOrchestrator {
|
||||
);
|
||||
}
|
||||
|
||||
await this.whmcsOrderService.acceptOrder(whmcsCreateResult.orderId, sfOrderId);
|
||||
return { orderId: whmcsCreateResult.orderId };
|
||||
const acceptResult = await this.whmcsOrderService.acceptOrder(
|
||||
whmcsCreateResult.orderId,
|
||||
sfOrderId
|
||||
);
|
||||
|
||||
// Update whmcsCreateResult with service IDs from AcceptOrder
|
||||
// (Services are created on accept, not on add)
|
||||
if (acceptResult.serviceIds.length > 0) {
|
||||
whmcsCreateResult.serviceIds = acceptResult.serviceIds;
|
||||
}
|
||||
|
||||
return { orderId: whmcsCreateResult.orderId, serviceIds: acceptResult.serviceIds };
|
||||
}),
|
||||
rollback: () => {
|
||||
if (whmcsCreateResult?.orderId) {
|
||||
@ -438,17 +451,28 @@ export class OrderFulfillmentOrchestrator {
|
||||
// Note: sim_fulfillment step was moved earlier in the flow (before WHMCS)
|
||||
{
|
||||
id: "sf_registration_complete",
|
||||
description: "Update Salesforce to Registration Completed",
|
||||
description: "Update Salesforce with WHMCS registration info",
|
||||
execute: this.createTrackedStep(context, "sf_registration_complete", async () => {
|
||||
const result = await this.salesforceService.updateOrder({
|
||||
// For SIM orders that are already "Activated", don't change Status
|
||||
// Only update WHMCS info. For non-SIM orders, set Status to "Activated"
|
||||
const isSIMOrder = context.orderDetails?.orderType === "SIM";
|
||||
const isAlreadyActivated = simFulfillmentResult?.activated === true;
|
||||
|
||||
const updatePayload: { Id: string; [key: string]: unknown } = {
|
||||
Id: sfOrderId,
|
||||
Status: "Registration Completed",
|
||||
Activation_Status__c: "Activated",
|
||||
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
// Only set Status if not already activated (non-SIM orders)
|
||||
if (!isSIMOrder || !isAlreadyActivated) {
|
||||
updatePayload.Status = "Activated";
|
||||
}
|
||||
|
||||
const result = await this.salesforceService.updateOrder(updatePayload);
|
||||
this.orderEvents.publish(sfOrderId, {
|
||||
orderId: sfOrderId,
|
||||
status: "Registration Completed",
|
||||
status: "Activated",
|
||||
activationStatus: "Activated",
|
||||
stage: "completed",
|
||||
source: "fulfillment",
|
||||
@ -500,7 +524,8 @@ export class OrderFulfillmentOrchestrator {
|
||||
if (serviceId) {
|
||||
await this.opportunityService.linkWhmcsServiceToOpportunity(
|
||||
opportunityId,
|
||||
serviceId
|
||||
serviceId,
|
||||
context.validation?.clientId // Pass client ID for WHMCS admin URL
|
||||
);
|
||||
}
|
||||
|
||||
@ -595,7 +620,7 @@ export class OrderFulfillmentOrchestrator {
|
||||
* 6. mapping (with SIM data for WHMCS)
|
||||
* 7. whmcs_create
|
||||
* 8. whmcs_accept
|
||||
* 9. sf_registration_complete
|
||||
* 9. sf_registration_complete (WHMCS info, skip Status for SIM)
|
||||
* 10. opportunity_update
|
||||
*/
|
||||
private initializeSteps(orderType?: string): OrderFulfillmentStep[] {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
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";
|
||||
@ -18,6 +19,7 @@ export class OrderFulfillmentValidator {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly salesforceAccountService: SalesforceAccountService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly paymentValidator: PaymentValidatorService
|
||||
) {}
|
||||
@ -62,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,
|
||||
@ -122,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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ import { Logger } from "nestjs-pino";
|
||||
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 { FreebitSemiBlackService } from "@bff/integrations/freebit/services/freebit-semiblack.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";
|
||||
@ -58,7 +57,6 @@ export interface SimFulfillmentResult {
|
||||
export class SimFulfillmentService {
|
||||
constructor(
|
||||
private readonly freebit: FreebitOrchestratorService,
|
||||
private readonly freebitSemiBlack: FreebitSemiBlackService,
|
||||
private readonly freebitAccountReg: FreebitAccountRegistrationService,
|
||||
private readonly freebitVoiceOptions: FreebitVoiceOptionsService,
|
||||
private readonly simInventory: SalesforceSIMInventoryService,
|
||||
@ -162,7 +160,7 @@ export class SimFulfillmentService {
|
||||
phoneNumber,
|
||||
};
|
||||
} else {
|
||||
// Physical SIM activation flow (PA05-18 + PA02-01 + PA05-05)
|
||||
// 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)",
|
||||
@ -265,19 +263,15 @@ export class SimFulfillmentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate Physical SIM via Freebit PA05-18 + PA02-01 + PA05-05 APIs
|
||||
* Activate Physical SIM via Freebit PA02-01 + PA05-05 APIs
|
||||
*
|
||||
* Flow for Semi-Black Physical SIMs:
|
||||
* 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 PA05-18 (Semi-Black Registration) to register the SIM
|
||||
* 5. Call Freebit PA02-01 (Account Registration) to create MVNO account
|
||||
* 6. Call Freebit PA05-05 (Voice Options) to configure voice features
|
||||
* 7. Update SIM Inventory status to "In Use"
|
||||
*
|
||||
* Note: PA05-18 must be called before PA02-01, otherwise PA02-01 returns
|
||||
* error 210 "アカウント不在エラー" (Account not found).
|
||||
* 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: {
|
||||
orderId: string;
|
||||
@ -298,7 +292,7 @@ export class SimFulfillmentService {
|
||||
contactIdentity,
|
||||
} = params;
|
||||
|
||||
this.logger.log("Starting Physical SIM activation (PA05-18 + PA02-01 + PA05-05)", {
|
||||
this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", {
|
||||
orderId,
|
||||
simInventoryId,
|
||||
planSku,
|
||||
@ -331,28 +325,7 @@ export class SimFulfillmentService {
|
||||
});
|
||||
|
||||
try {
|
||||
// Step 4: Call Freebit PA05-18 (Semi-Black Registration)
|
||||
// This registers the semi-black SIM in Freebit's system
|
||||
// Must be called BEFORE PA02-01 or we get error 210 "アカウント不在エラー"
|
||||
this.logger.log("Calling PA05-18 Semi-Black Registration", {
|
||||
orderId,
|
||||
account: accountPhoneNumber,
|
||||
productNumber: simRecord.ptNumber,
|
||||
planCode,
|
||||
});
|
||||
|
||||
await this.freebitSemiBlack.registerSemiBlackAccount({
|
||||
account: accountPhoneNumber,
|
||||
productNumber: simRecord.ptNumber,
|
||||
planCode,
|
||||
});
|
||||
|
||||
this.logger.log("PA05-18 Semi-Black Registration successful", {
|
||||
orderId,
|
||||
account: accountPhoneNumber,
|
||||
});
|
||||
|
||||
// Step 5: Call Freebit PA02-01 (Account Registration)
|
||||
// Step 4: Call Freebit PA02-01 (Account Registration)
|
||||
this.logger.log("Calling PA02-01 Account Registration", {
|
||||
orderId,
|
||||
account: accountPhoneNumber,
|
||||
@ -362,6 +335,7 @@ export class SimFulfillmentService {
|
||||
await this.freebitAccountReg.registerAccount({
|
||||
account: accountPhoneNumber,
|
||||
planCode,
|
||||
createType: "new",
|
||||
});
|
||||
|
||||
this.logger.log("PA02-01 Account Registration successful", {
|
||||
@ -369,7 +343,7 @@ export class SimFulfillmentService {
|
||||
account: accountPhoneNumber,
|
||||
});
|
||||
|
||||
// Step 6: Call Freebit PA05-05 (Voice Options Registration)
|
||||
// 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", {
|
||||
@ -404,8 +378,8 @@ export class SimFulfillmentService {
|
||||
});
|
||||
}
|
||||
|
||||
// Step 7: Update SIM Inventory status to "In Use"
|
||||
await this.simInventory.markAsInUse(simInventoryId);
|
||||
// Step 6: Update SIM Inventory status to "Assigned"
|
||||
await this.simInventory.markAsAssigned(simInventoryId);
|
||||
|
||||
this.logger.log("Physical SIM activated successfully", {
|
||||
orderId,
|
||||
|
||||
@ -32,7 +32,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",
|
||||
@ -67,7 +67,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"
|
||||
@ -116,7 +116,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",
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
Settings,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/atoms/Spinner";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
@ -284,6 +285,7 @@ export function PublicLandingView() {
|
||||
const [heroRef, heroInView] = useInView();
|
||||
const [trustRef, trustInView] = useInView();
|
||||
const [solutionsRef, solutionsInView] = useInView();
|
||||
const [popularRef, popularInView] = useInView();
|
||||
const [supportRef, supportInView] = useInView();
|
||||
const [contactRef, contactInView] = useInView();
|
||||
|
||||
@ -550,16 +552,14 @@ export function PublicLandingView() {
|
||||
}`}
|
||||
>
|
||||
{/* Gradient Background */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br from-slate-50 via-white to-sky-50/80"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-50 via-white to-sky-50/80" />
|
||||
|
||||
{/* Dot Grid Pattern Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at center, oklch(0.65 0.05 234.4 / 0.15) 1px, transparent 1px)`,
|
||||
backgroundSize: '24px 24px',
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -567,7 +567,7 @@ export function PublicLandingView() {
|
||||
<div
|
||||
className="absolute -top-32 -right-32 w-96 h-96 rounded-full pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, oklch(0.85 0.08 200 / 0.3) 0%, transparent 70%)',
|
||||
background: "radial-gradient(circle, oklch(0.85 0.08 200 / 0.3) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -719,50 +719,50 @@ export function PublicLandingView() {
|
||||
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-[#e8f5ff] pointer-events-none" />
|
||||
|
||||
<div 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">
|
||||
<div className="relative w-full overflow-hidden rounded-2xl shadow-md border border-border/60 bg-white aspect-[4/5]">
|
||||
<Image
|
||||
src="/assets/images/Why_us.png"
|
||||
alt="Team collaborating with trust and excellence"
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 40vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-primary uppercase tracking-wide mb-3">
|
||||
Built on Trust and Excellence
|
||||
</h2>
|
||||
<p className="text-xl sm:text-2xl font-semibold text-foreground leading-relaxed">
|
||||
For over two decades, we've been helping foreigners, expats, and international
|
||||
businesses in Japan navigate the tech landscape with confidence.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.1fr] gap-10 lg:gap-14 items-center">
|
||||
<div className="relative w-full overflow-hidden rounded-2xl shadow-md border border-border/60 bg-white aspect-[4/5]">
|
||||
<Image
|
||||
src="/assets/images/Why_us.png"
|
||||
alt="Team collaborating with trust and excellence"
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 40vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-primary uppercase tracking-wide mb-3">
|
||||
Built on Trust and Excellence
|
||||
</h2>
|
||||
<p className="text-xl sm:text-2xl font-semibold text-foreground leading-relaxed">
|
||||
For over two decades, we've been helping foreigners, expats, and
|
||||
international businesses in Japan navigate the tech landscape with confidence.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 text-foreground">
|
||||
{[
|
||||
"Full English support, no Japanese needed",
|
||||
"Foreign credit cards accepted",
|
||||
"Bilingual contracts and documentation",
|
||||
].map(item => (
|
||||
<li key={item} className="flex items-center gap-3 text-base font-semibold">
|
||||
<BadgeCheck className="h-5 w-5 text-primary" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/about"
|
||||
className="inline-flex items-center gap-2 text-primary font-semibold hover:text-primary/80 transition-colors"
|
||||
>
|
||||
About our company
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<ul className="space-y-3 text-foreground">
|
||||
{[
|
||||
"Full English support, no Japanese needed",
|
||||
"Foreign credit cards accepted",
|
||||
"Bilingual contracts and documentation",
|
||||
].map(item => (
|
||||
<li key={item} className="flex items-center gap-3 text-base font-semibold">
|
||||
<BadgeCheck className="h-5 w-5 text-primary" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/about"
|
||||
className="inline-flex items-center gap-2 text-primary font-semibold hover:text-primary/80 transition-colors"
|
||||
>
|
||||
About our company
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Solutions Carousel */}
|
||||
@ -848,6 +848,130 @@ export function PublicLandingView() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Most Popular Services Section */}
|
||||
<section
|
||||
ref={popularRef as React.RefObject<HTMLElement>}
|
||||
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white py-14 sm:py-16 transition-all duration-700 ${
|
||||
popularInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-10 sm:mb-12">
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground mb-4">
|
||||
Most Popular Services
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Get connected with ease. No Japanese required, no complicated paperwork, and full
|
||||
English support every step of the way.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Two-column card layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
||||
{/* Internet Plans Card */}
|
||||
<div className="relative rounded-2xl border border-sky-200 bg-white shadow-lg shadow-sky-100/50 p-6 sm:p-8 flex flex-col">
|
||||
{/* Popular Badge */}
|
||||
<div className="absolute top-4 right-4 sm:top-6 sm:right-6">
|
||||
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-bold text-white uppercase tracking-wide">
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Icon Container */}
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-sky-50 flex items-center justify-center mb-5">
|
||||
<Wifi className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-foreground mb-3">Internet Plans</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground mb-5 leading-relaxed">
|
||||
High-speed fiber internet on Japan's reliable NTT network. We handle all the
|
||||
Japanese paperwork and coordinate installation in English.
|
||||
</p>
|
||||
|
||||
{/* Feature List */}
|
||||
<ul className="space-y-3 mb-6 flex-grow">
|
||||
{[
|
||||
"Speeds up to 10 Gbps available",
|
||||
"English installation coordination",
|
||||
"No Japanese contracts to sign",
|
||||
"Foreign credit cards accepted",
|
||||
].map(feature => (
|
||||
<li key={feature} className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
|
||||
<Check className="h-3 w-3 text-primary" />
|
||||
</span>
|
||||
<span className="text-sm text-foreground">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Link
|
||||
href="/services/internet"
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
View Plans
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Phone Plans Card */}
|
||||
<div className="relative rounded-2xl border border-sky-200 bg-white shadow-lg shadow-sky-100/50 p-6 sm:p-8 flex flex-col">
|
||||
{/* Popular Badge */}
|
||||
<div className="absolute top-4 right-4 sm:top-6 sm:right-6">
|
||||
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-bold text-white uppercase tracking-wide">
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Icon Container */}
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-sky-50 flex items-center justify-center mb-5">
|
||||
<Smartphone className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-foreground mb-3">Phone Plans</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground mb-5 leading-relaxed">
|
||||
Mobile SIM cards with voice and data on Japan's top network. Sign up online, no
|
||||
hanko needed, and get your SIM delivered fast.
|
||||
</p>
|
||||
|
||||
{/* Feature List */}
|
||||
<ul className="space-y-3 mb-6 flex-grow">
|
||||
{[
|
||||
"Data-only and voice + data options",
|
||||
"Keep your number with MNP transfer",
|
||||
"eSIM available for instant activation",
|
||||
"Flexible plans with no long-term contracts",
|
||||
].map(feature => (
|
||||
<li key={feature} className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
|
||||
<Check className="h-3 w-3 text-primary" />
|
||||
</span>
|
||||
<span className="text-sm text-foreground">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Link
|
||||
href="/services/sim"
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
View Plans
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Remote Support - Full section with mobile-optimized card layout */}
|
||||
<section
|
||||
ref={supportRef as React.RefObject<HTMLElement>}
|
||||
|
||||
@ -23,6 +23,18 @@ import {
|
||||
* and mission statement for Assist Solutions.
|
||||
*/
|
||||
export function AboutUsView() {
|
||||
// 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" },
|
||||
];
|
||||
|
||||
const values = [
|
||||
{
|
||||
text: "Make technology accessible for everyone, regardless of language barriers.",
|
||||
@ -127,9 +139,6 @@ export function AboutUsView() {
|
||||
/>
|
||||
{/* Subtle gradient overlay for depth */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white/80 via-transparent to-white/40" />
|
||||
{/* 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="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">
|
||||
@ -163,6 +172,79 @@ export function AboutUsView() {
|
||||
</div>
|
||||
</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 */}
|
||||
@ -219,59 +301,59 @@ export function AboutUsView() {
|
||||
<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 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>
|
||||
|
||||
{/* 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 */}
|
||||
{/* 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
|
||||
className={`inline-flex items-center justify-center w-12 h-12 rounded-xl border mb-4 ${value.color}`}
|
||||
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"
|
||||
>
|
||||
{value.icon}
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Corporate Data Section */}
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user