Decompose large services and components for improved maintainability

Phase 5: BFF Service Decomposition
- Split SalesforceOpportunityService (1055 lines) into focused services:
  - OpportunityQueryService: find/get operations
  - OpportunityCancellationService: cancellation workflows
  - OpportunityMutationService: create/update/link operations
  - SalesforceOpportunityService: thin facade (227 lines)

Phase 6: Portal Component Decomposition
- Refactor AccountCheckoutContainer (798 → 357 lines, 55% reduction)
- Extract reusable checkout section components:
  - ResidenceCardUploadInput: eliminates 3x duplicated upload UI
  - PaymentMethodSection: payment method handling
  - IdentityVerificationSection: identity verification states
  - OrderSubmitSection: order summary and submission

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
barsa 2026-01-13 14:38:27 +09:00
parent f447ba1800
commit b49c94994d
13 changed files with 1685 additions and 1378 deletions

View File

@ -11,6 +11,9 @@ import { OpportunityResolutionService } from "./services/opportunity-resolution.
import { SalesforceOrderFieldConfigModule } from "./config/salesforce-order-field-config.module.js";
import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js";
import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard.js";
import { OpportunityQueryService } from "./services/opportunity/opportunity-query.service.js";
import { OpportunityCancellationService } from "./services/opportunity/opportunity-cancellation.service.js";
import { OpportunityMutationService } from "./services/opportunity/opportunity-mutation.service.js";
@Module({
imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule],
@ -19,6 +22,11 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
SalesforceAccountService,
SalesforceOrderService,
SalesforceCaseService,
// Opportunity decomposed services
OpportunityQueryService,
OpportunityCancellationService,
OpportunityMutationService,
// Opportunity facade (depends on decomposed services)
SalesforceOpportunityService,
OpportunityResolutionService,
SalesforceService,

View File

@ -0,0 +1,21 @@
/**
* Opportunity Services - Public API
*
* Decomposed Salesforce Opportunity services for focused responsibilities.
*/
export { OpportunityQueryService } from "./opportunity-query.service.js";
export type {
InternetCancellationStatusResult,
SimCancellationStatusResult,
} from "./opportunity-query.service.js";
export { OpportunityCancellationService } from "./opportunity-cancellation.service.js";
export { OpportunityMutationService } from "./opportunity-mutation.service.js";
export type {
SalesforceOpportunityRecord,
InternetCancellationOpportunityDataInput,
SimCancellationOpportunityDataInput,
} from "./opportunity.types.js";

View File

@ -0,0 +1,195 @@
/**
* Opportunity Cancellation Service
*
* Handles cancellation-specific operations for Salesforce Opportunities.
* Extracted from SalesforceOpportunityService for single-responsibility.
*/
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 {
type CancellationOpportunityData,
OPPORTUNITY_STAGE,
} from "@customer-portal/domain/opportunity";
import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js";
import {
type InternetCancellationOpportunityDataInput,
type SimCancellationOpportunityDataInput,
isRecord,
requireStringField,
} from "./opportunity.types.js";
@Injectable()
export class OpportunityCancellationService {
constructor(
private readonly sf: SalesforceConnection,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Update Opportunity with Internet cancellation data from form submission
*
* Sets:
* - Stage to Cancelling
* - ScheduledCancellationDateAndTime__c
* - CancellationNotice__c to (received)
* - LineReturn__c to NotYet
*/
async updateInternetCancellationData(
opportunityId: string,
data: InternetCancellationOpportunityDataInput
): Promise<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
const safeData = (() => {
const unknownData: unknown = data;
if (!isRecord(unknownData)) throw new Error("Invalid cancellation data");
return {
scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"),
cancellationNotice: requireStringField(unknownData, "cancellationNotice"),
lineReturnStatus: requireStringField(unknownData, "lineReturnStatus"),
};
})();
this.logger.log("Updating Opportunity with Internet cancellation data", {
opportunityId: safeOppId,
scheduledDate: safeData.scheduledCancellationDate,
cancellationNotice: safeData.cancellationNotice,
});
const payload: Record<string, unknown> = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: safeData.scheduledCancellationDate,
[OPPORTUNITY_FIELD_MAP.cancellationNotice]: safeData.cancellationNotice,
[OPPORTUNITY_FIELD_MAP.lineReturnStatus]: safeData.lineReturnStatus,
};
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.log("Opportunity Internet cancellation data updated successfully", {
opportunityId: safeOppId,
scheduledDate: safeData.scheduledCancellationDate,
});
} catch (error) {
this.logger.error("Failed to update Opportunity Internet cancellation data", {
error: extractErrorMessage(error),
opportunityId: safeOppId,
});
throw new Error("Failed to update cancellation information");
}
}
/**
* Update Opportunity with SIM cancellation data from form submission
*
* Sets:
* - Stage to Cancelling
* - SIMScheduledCancellationDateAndTime__c
* - SIMCancellationNotice__c to (received)
*/
async updateSimCancellationData(
opportunityId: string,
data: SimCancellationOpportunityDataInput
): Promise<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
const safeData = (() => {
const unknownData: unknown = data;
if (!isRecord(unknownData)) throw new Error("Invalid SIM cancellation data");
return {
scheduledCancellationDate: requireStringField(unknownData, "scheduledCancellationDate"),
cancellationNotice: requireStringField(unknownData, "cancellationNotice"),
};
})();
this.logger.log("Updating Opportunity with SIM cancellation data", {
opportunityId: safeOppId,
scheduledDate: safeData.scheduledCancellationDate,
cancellationNotice: safeData.cancellationNotice,
});
const payload: Record<string, unknown> = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
[OPPORTUNITY_FIELD_MAP.simScheduledCancellationDate]: safeData.scheduledCancellationDate,
[OPPORTUNITY_FIELD_MAP.simCancellationNotice]: safeData.cancellationNotice,
};
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.log("Opportunity SIM cancellation data updated successfully", {
opportunityId: safeOppId,
scheduledDate: safeData.scheduledCancellationDate,
});
} catch (error) {
this.logger.error("Failed to update Opportunity SIM cancellation data", {
error: extractErrorMessage(error),
opportunityId: safeOppId,
});
throw new Error("Failed to update SIM cancellation information");
}
}
/**
* @deprecated Use updateInternetCancellationData or updateSimCancellationData
*/
async updateCancellationData(
opportunityId: string,
data: CancellationOpportunityData
): Promise<void> {
return this.updateInternetCancellationData(opportunityId, data);
}
/**
* Mark cancellation as complete by updating stage to Cancelled
*/
async markCancellationComplete(opportunityId: string): Promise<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Marking Opportunity cancellation complete", {
opportunityId: safeOppId,
});
const payload: Record<string, unknown> = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLED,
};
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.log("Opportunity cancellation marked complete", {
opportunityId: safeOppId,
});
} catch (error) {
this.logger.error("Failed to mark Opportunity cancellation complete", {
error: extractErrorMessage(error),
opportunityId: safeOppId,
});
throw new Error("Failed to mark cancellation complete");
}
}
}

View File

@ -0,0 +1,281 @@
/**
* Opportunity Mutation Service
*
* Handles create, update, and link operations for Salesforce Opportunities.
* Extracted from SalesforceOpportunityService for single-responsibility.
*/
import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
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 {
type OpportunityStageValue,
type OpportunityProductTypeValue,
type CreateOpportunityRequest,
OPPORTUNITY_STAGE,
APPLICATION_STAGE,
OPPORTUNITY_PRODUCT_TYPE,
getDefaultCommodityType,
} from "@customer-portal/domain/opportunity";
import { OPPORTUNITY_FIELD_MAP } from "../../constants/index.js";
@Injectable()
export class OpportunityMutationService {
private readonly opportunityRecordTypeIds: Partial<
Record<OpportunityProductTypeValue, string | undefined>
>;
constructor(
private readonly sf: SalesforceConnection,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
this.opportunityRecordTypeIds = {
[OPPORTUNITY_PRODUCT_TYPE.INTERNET]: this.configService.get<string>(
"OPPORTUNITY_RECORD_TYPE_ID_INTERNET"
),
[OPPORTUNITY_PRODUCT_TYPE.SIM]: this.configService.get<string>(
"OPPORTUNITY_RECORD_TYPE_ID_SIM"
),
[OPPORTUNITY_PRODUCT_TYPE.VPN]: this.configService.get<string>(
"OPPORTUNITY_RECORD_TYPE_ID_VPN"
),
};
}
/**
* Create a new Opportunity in Salesforce
*/
async createOpportunity(request: CreateOpportunityRequest): Promise<string> {
const safeAccountId = assertSalesforceId(request.accountId, "accountId");
this.logger.log("Creating Opportunity for service lifecycle tracking", {
accountId: safeAccountId,
productType: request.productType,
stage: request.stage,
source: request.source,
});
const opportunityName = `Portal - ${request.productType}`;
const closeDate =
request.closeDate ?? this.calculateCloseDate(request.productType, request.stage);
const applicationStage = request.applicationStage ?? APPLICATION_STAGE.INTRO_1;
const commodityType = getDefaultCommodityType(request.productType);
const recordTypeId = this.resolveOpportunityRecordTypeId(request.productType);
const payload: Record<string, unknown> = {
[OPPORTUNITY_FIELD_MAP.name]: opportunityName,
[OPPORTUNITY_FIELD_MAP.accountId]: safeAccountId,
[OPPORTUNITY_FIELD_MAP.stage]: request.stage,
[OPPORTUNITY_FIELD_MAP.closeDate]: closeDate,
[OPPORTUNITY_FIELD_MAP.applicationStage]: applicationStage,
[OPPORTUNITY_FIELD_MAP.commodityType]: commodityType,
...(recordTypeId ? { RecordTypeId: recordTypeId } : {}),
};
if (request.source) {
payload[OPPORTUNITY_FIELD_MAP.source] = request.source;
}
try {
const createMethod = this.sf.sobject("Opportunity").create;
if (!createMethod) {
throw new Error("Salesforce Opportunity create method not available");
}
const result = (await createMethod(payload)) as { id?: string; success?: boolean };
if (!result?.id) {
throw new Error("Salesforce did not return Opportunity ID");
}
this.logger.log("Opportunity created successfully", {
opportunityId: result.id,
productType: request.productType,
stage: request.stage,
});
return result.id;
} catch (error) {
const errorDetails: Record<string, unknown> = {
error: extractErrorMessage(error),
accountId: safeAccountId,
productType: request.productType,
stage: request.stage,
payloadFields: Object.keys(payload),
};
if (error && typeof error === "object") {
const err = error as Record<string, unknown>;
if (err.errorCode) errorDetails.errorCode = err.errorCode;
if (err.fields) errorDetails.fields = err.fields;
if (err.message) errorDetails.rawMessage = err.message;
}
this.logger.error(errorDetails, "Failed to create Opportunity");
throw new Error("Failed to create service lifecycle record");
}
}
/**
* Update Opportunity stage
*/
async updateStage(
opportunityId: string,
stage: OpportunityStageValue,
reason?: string
): Promise<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Updating Opportunity stage", {
opportunityId: safeOppId,
newStage: stage,
reason,
});
const payload: Record<string, unknown> = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.stage]: stage,
};
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.log("Opportunity stage updated successfully", {
opportunityId: safeOppId,
stage,
});
} catch (error) {
this.logger.error("Failed to update Opportunity stage", {
error: extractErrorMessage(error),
opportunityId: safeOppId,
stage,
});
throw new Error("Failed to update service lifecycle stage");
}
}
/**
* Link a WHMCS Service ID to an Opportunity
*/
async linkWhmcsServiceToOpportunity(
opportunityId: string,
whmcsServiceId: number
): Promise<void> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Linking WHMCS Service to Opportunity", {
opportunityId: safeOppId,
whmcsServiceId,
});
const payload: Record<string, unknown> = {
Id: safeOppId,
[OPPORTUNITY_FIELD_MAP.whmcsServiceId]: whmcsServiceId,
};
try {
const updateMethod = this.sf.sobject("Opportunity").update;
if (!updateMethod) {
throw new Error("Salesforce Opportunity update method not available");
}
await updateMethod(payload as Record<string, unknown> & { Id: string });
this.logger.log("WHMCS Service linked to Opportunity", {
opportunityId: safeOppId,
whmcsServiceId,
});
} catch (error) {
this.logger.error("Failed to link WHMCS Service to Opportunity", {
error: extractErrorMessage(error),
opportunityId: safeOppId,
whmcsServiceId,
});
// Don't throw - this is a non-critical update
}
}
/**
* Link an Order to an Opportunity (update Order.OpportunityId)
*/
async linkOrderToOpportunity(orderId: string, opportunityId: string): Promise<void> {
const safeOrderId = assertSalesforceId(orderId, "orderId");
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
this.logger.log("Linking Order to Opportunity", {
orderId: safeOrderId,
opportunityId: safeOppId,
});
try {
const updateMethod = this.sf.sobject("Order").update;
if (!updateMethod) {
throw new Error("Salesforce Order update method not available");
}
await updateMethod({
Id: safeOrderId,
OpportunityId: safeOppId,
});
this.logger.log("Order linked to Opportunity", {
orderId: safeOrderId,
opportunityId: safeOppId,
});
} catch (error) {
this.logger.error("Failed to link Order to Opportunity", {
error: extractErrorMessage(error),
orderId: safeOrderId,
opportunityId: safeOppId,
});
// Don't throw - this is a non-critical update
}
}
// ==========================================================================
// Private Helpers
// ==========================================================================
private calculateCloseDate(
productType: OpportunityProductTypeValue,
stage: OpportunityStageValue
): string {
const today = new Date();
let daysToAdd: number;
switch (stage) {
case OPPORTUNITY_STAGE.INTRODUCTION:
daysToAdd = 30;
break;
case OPPORTUNITY_STAGE.READY:
daysToAdd = 14;
break;
case OPPORTUNITY_STAGE.POST_PROCESSING:
daysToAdd = 7;
break;
default:
daysToAdd = 30;
}
const closeDate = new Date(today);
closeDate.setDate(closeDate.getDate() + daysToAdd);
return closeDate.toISOString().slice(0, 10);
}
private resolveOpportunityRecordTypeId(
productType: OpportunityProductTypeValue
): string | undefined {
const recordTypeId = this.opportunityRecordTypeIds[productType];
return recordTypeId ? assertSalesforceId(recordTypeId, "opportunityRecordTypeId") : undefined;
}
}

View File

@ -0,0 +1,450 @@
/**
* Opportunity Query Service
*
* Handles all read/lookup operations for Salesforce Opportunities.
* Extracted from SalesforceOpportunityService for single-responsibility.
*/
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 type { SalesforceResponse } from "@customer-portal/domain/common/providers";
import {
type OpportunityStageValue,
type OpportunityProductTypeValue,
type CancellationNoticeValue,
type LineReturnStatusValue,
type CommodityTypeValue,
type ApplicationStageValue,
type OpportunitySourceValue,
type OpportunityRecord,
OPPORTUNITY_STAGE,
OPEN_OPPORTUNITY_STAGES,
COMMODITY_TYPE,
OPPORTUNITY_PRODUCT_TYPE,
getCommodityTypeProductType,
} from "@customer-portal/domain/opportunity";
import {
OPPORTUNITY_FIELD_MAP,
OPPORTUNITY_MATCH_QUERY_FIELDS,
OPPORTUNITY_DETAIL_QUERY_FIELDS,
OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS,
OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS,
} from "../../constants/index.js";
import type { SalesforceOpportunityRecord } from "./opportunity.types.js";
export interface InternetCancellationStatusResult {
stage: OpportunityStageValue;
isPending: boolean;
isComplete: boolean;
scheduledEndDate?: string;
rentalReturnStatus?: LineReturnStatusValue;
}
export interface SimCancellationStatusResult {
stage: OpportunityStageValue;
isPending: boolean;
isComplete: boolean;
scheduledEndDate?: string;
}
@Injectable()
export class OpportunityQueryService {
constructor(
private readonly sf: SalesforceConnection,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Find an open Opportunity for an account by product type
*/
async findOpenOpportunityForAccount(
accountId: string,
productType: OpportunityProductTypeValue,
options?: { stages?: OpportunityStageValue[] }
): Promise<string | null> {
const safeAccountId = assertSalesforceId(accountId, "accountId");
const commodityTypeValues = this.getCommodityTypesForProductType(productType);
const stages =
Array.isArray(options?.stages) && options?.stages.length > 0
? options.stages
: OPEN_OPPORTUNITY_STAGES;
this.logger.debug("Looking for open Opportunity", {
accountId: safeAccountId,
productType,
commodityTypes: commodityTypeValues,
stages,
});
const stageList = stages.map((s: OpportunityStageValue) => `'${s}'`).join(", ");
const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", ");
const soql = `
SELECT ${OPPORTUNITY_MATCH_QUERY_FIELDS.join(", ")}
FROM Opportunity
WHERE ${OPPORTUNITY_FIELD_MAP.accountId} = '${safeAccountId}'
AND ${OPPORTUNITY_FIELD_MAP.commodityType} IN (${commodityTypeList})
AND ${OPPORTUNITY_FIELD_MAP.stage} IN (${stageList})
AND ${OPPORTUNITY_FIELD_MAP.isClosed} = false
ORDER BY CreatedDate DESC
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:findOpenForAccount",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (record) {
this.logger.debug("Found open Opportunity", {
opportunityId: record.Id,
stage: record.StageName,
productType,
});
return record.Id;
}
this.logger.debug("No open Opportunity found", {
accountId: safeAccountId,
productType,
});
return null;
} catch (error) {
const errorDetails: Record<string, unknown> = {
error: extractErrorMessage(error),
accountId: safeAccountId,
productType,
};
if (error && typeof error === "object") {
const err = error as Record<string, unknown>;
if (err.errorCode) errorDetails.errorCode = err.errorCode;
if (err.message) errorDetails.rawMessage = err.message;
}
this.logger.error(errorDetails, "Failed to find open Opportunity");
return null;
}
}
/**
* Find Opportunity linked to an Order
*/
async findOpportunityByOrderId(orderId: string): Promise<string | null> {
const safeOrderId = assertSalesforceId(orderId, "orderId");
this.logger.debug("Looking for Opportunity by Order ID", {
orderId: safeOrderId,
});
const soql = `
SELECT OpportunityId
FROM Order
WHERE Id = '${safeOrderId}'
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:findByOrderId",
})) as SalesforceResponse<{ OpportunityId?: string }>;
const record = result.records?.[0];
const opportunityId = record?.OpportunityId;
if (opportunityId) {
this.logger.debug("Found Opportunity for Order", {
orderId: safeOrderId,
opportunityId,
});
return opportunityId;
}
return null;
} catch (error) {
this.logger.error("Failed to find Opportunity by Order ID", {
error: extractErrorMessage(error),
orderId: safeOrderId,
});
return null;
}
}
/**
* Find Opportunity by WHMCS Service ID
*/
async findOpportunityByWhmcsServiceId(whmcsServiceId: number): Promise<string | null> {
this.logger.debug("Looking for Opportunity by WHMCS Service ID", {
whmcsServiceId,
});
const soql = `
SELECT Id, ${OPPORTUNITY_FIELD_MAP.stage}
FROM Opportunity
WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId}
ORDER BY CreatedDate DESC
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:findByWhmcsServiceId",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (record) {
this.logger.debug("Found Opportunity for WHMCS Service", {
opportunityId: record.Id,
whmcsServiceId,
});
return record.Id;
}
return null;
} catch (error) {
this.logger.error("Failed to find Opportunity by WHMCS Service ID", {
error: extractErrorMessage(error),
whmcsServiceId,
});
return null;
}
}
/**
* Get full Opportunity details by ID
*/
async getOpportunityById(opportunityId: string): Promise<OpportunityRecord | null> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
const soql = `
SELECT ${OPPORTUNITY_DETAIL_QUERY_FIELDS.join(", ")}
FROM Opportunity
WHERE Id = '${safeOppId}'
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:getById",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (!record) {
return null;
}
return this.transformToOpportunityRecord(record);
} catch (error) {
this.logger.error("Failed to get Opportunity by ID", {
error: extractErrorMessage(error),
opportunityId: safeOppId,
});
return null;
}
}
/**
* Get Internet cancellation status by WHMCS Service ID
*/
async getInternetCancellationStatus(
whmcsServiceId: number
): Promise<InternetCancellationStatusResult | null> {
const soql = `
SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")}
FROM Opportunity
WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId}
ORDER BY CreatedDate DESC
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:getInternetCancellationStatus",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (!record) return null;
return this.buildInternetCancellationResult(record);
} catch (error) {
this.logger.error("Failed to get Internet cancellation status", {
error: extractErrorMessage(error),
whmcsServiceId,
});
return null;
}
}
/**
* Get Internet cancellation status by Opportunity ID
*/
async getInternetCancellationStatusByOpportunityId(
opportunityId: string
): Promise<InternetCancellationStatusResult | null> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
const soql = `
SELECT ${OPPORTUNITY_INTERNET_CANCELLATION_QUERY_FIELDS.join(", ")}
FROM Opportunity
WHERE Id = '${safeOppId}'
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:getInternetCancellationStatusByOpportunityId",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (!record) return null;
return this.buildInternetCancellationResult(record);
} catch (error) {
this.logger.error("Failed to get Internet cancellation status by Opportunity ID", {
error: extractErrorMessage(error),
opportunityId: safeOppId,
});
return null;
}
}
/**
* Get SIM cancellation status by WHMCS Service ID
*/
async getSimCancellationStatus(
whmcsServiceId: number
): Promise<SimCancellationStatusResult | null> {
const soql = `
SELECT ${OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS.join(", ")}
FROM Opportunity
WHERE ${OPPORTUNITY_FIELD_MAP.whmcsServiceId} = ${whmcsServiceId}
ORDER BY CreatedDate DESC
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:getSimCancellationStatus",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (!record) return null;
return this.buildSimCancellationResult(record);
} catch (error) {
this.logger.error("Failed to get SIM cancellation status", {
error: extractErrorMessage(error),
whmcsServiceId,
});
return null;
}
}
/**
* Get SIM cancellation status by Opportunity ID
*/
async getSimCancellationStatusByOpportunityId(
opportunityId: string
): Promise<SimCancellationStatusResult | null> {
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
const soql = `
SELECT ${OPPORTUNITY_SIM_CANCELLATION_QUERY_FIELDS.join(", ")}
FROM Opportunity
WHERE Id = '${safeOppId}'
LIMIT 1
`;
try {
const result = (await this.sf.query(soql, {
label: "opportunity:getSimCancellationStatusByOpportunityId",
})) as SalesforceResponse<SalesforceOpportunityRecord>;
const record = result.records?.[0];
if (!record) return null;
return this.buildSimCancellationResult(record);
} catch (error) {
this.logger.error("Failed to get SIM cancellation status by Opportunity ID", {
error: extractErrorMessage(error),
opportunityId: safeOppId,
});
return null;
}
}
// ==========================================================================
// Private Helpers
// ==========================================================================
private getCommodityTypesForProductType(
productType: OpportunityProductTypeValue
): CommodityTypeValue[] {
switch (productType) {
case OPPORTUNITY_PRODUCT_TYPE.INTERNET:
return [COMMODITY_TYPE.PERSONAL_HOME_INTERNET, COMMODITY_TYPE.CORPORATE_HOME_INTERNET];
case OPPORTUNITY_PRODUCT_TYPE.SIM:
return [COMMODITY_TYPE.SIM];
case OPPORTUNITY_PRODUCT_TYPE.VPN:
return [COMMODITY_TYPE.VPN];
default:
return [];
}
}
private buildInternetCancellationResult(
record: SalesforceOpportunityRecord
): InternetCancellationStatusResult {
const stage = record.StageName as OpportunityStageValue;
return {
stage,
isPending: stage === OPPORTUNITY_STAGE.CANCELLING,
isComplete: stage === OPPORTUNITY_STAGE.CANCELLED,
scheduledEndDate: record.ScheduledCancellationDateAndTime__c,
rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
};
}
private buildSimCancellationResult(
record: SalesforceOpportunityRecord
): SimCancellationStatusResult {
const stage = record.StageName as OpportunityStageValue;
return {
stage,
isPending: stage === OPPORTUNITY_STAGE.CANCELLING,
isComplete: stage === OPPORTUNITY_STAGE.CANCELLED,
scheduledEndDate: record.SIMScheduledCancellationDateAndTime__c,
};
}
private transformToOpportunityRecord(record: SalesforceOpportunityRecord): OpportunityRecord {
const commodityType = record.CommodityType as CommodityTypeValue | undefined;
const productType = commodityType ? getCommodityTypeProductType(commodityType) : undefined;
return {
id: record.Id,
name: record.Name,
accountId: record.AccountId,
stage: record.StageName as OpportunityStageValue,
closeDate: record.CloseDate,
commodityType,
productType: productType ?? undefined,
source: record.Opportunity_Source__c as OpportunitySourceValue | undefined,
applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined,
isClosed: record.IsClosed,
whmcsServiceId: record.WHMCS_Service_ID__c,
scheduledCancellationDate: record.ScheduledCancellationDateAndTime__c,
cancellationNotice: record.CancellationNotice__c as CancellationNoticeValue | undefined,
lineReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
createdDate: record.CreatedDate,
lastModifiedDate: record.LastModifiedDate,
};
}
}

View File

@ -0,0 +1,55 @@
/**
* Shared types for Opportunity services
*/
import type { CancellationOpportunityData } from "@customer-portal/domain/opportunity";
/**
* Raw Opportunity record from Salesforce query
*/
export interface SalesforceOpportunityRecord {
Id: string;
Name: string;
AccountId: string;
StageName: string;
CloseDate: string;
IsClosed: boolean;
IsWon?: boolean;
CreatedDate: string;
LastModifiedDate: string;
// Existing custom fields
Application_Stage__c?: string;
CommodityType?: string;
// Internet cancellation fields
ScheduledCancellationDateAndTime__c?: string;
CancellationNotice__c?: string;
LineReturn__c?: string;
// SIM cancellation fields
SIMCancellationNotice__c?: string;
SIMScheduledCancellationDateAndTime__c?: string;
// Portal integration custom fields
Opportunity_Source__c?: string;
WHMCS_Service_ID__c?: number;
// Relationship fields
Account?: { Name?: string };
}
export type InternetCancellationOpportunityDataInput = Pick<
CancellationOpportunityData,
"scheduledCancellationDate" | "cancellationNotice" | "lineReturnStatus"
>;
export type SimCancellationOpportunityDataInput = Pick<
CancellationOpportunityData,
"scheduledCancellationDate" | "cancellationNotice"
>;
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function requireStringField(record: Record<string, unknown>, field: string): string {
const value = record[field];
if (typeof value === "string") return value;
throw new Error(`Invalid ${field}`);
}

View File

@ -2,14 +2,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ShieldCheck, CreditCard } from "lucide-react";
import { ShieldCheck } from "lucide-react";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { InlineToast } from "@/components/atoms/inline-toast";
import { StatusPill } from "@/components/atoms/status-pill";
import { AddressConfirmation } from "@/features/services/components/base/AddressConfirmation";
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
import { ordersService } from "@/features/orders/api/orders.api";
@ -33,6 +32,11 @@ import {
} from "@customer-portal/domain/orders";
import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils";
import { CheckoutStatusBanners } from "./CheckoutStatusBanners";
import {
PaymentMethodSection,
IdentityVerificationSection,
OrderSubmitSection,
} from "./checkout-sections";
export function AccountCheckoutContainer() {
const router = useRouter();
@ -54,6 +58,7 @@ export function AccountCheckoutContainer() {
const isInternetOrder = orderType === ORDER_TYPE.INTERNET;
// Active subscriptions check
const { data: activeSubs } = useActiveSubscriptions();
const hasActiveInternetSubscription = useMemo(() => {
if (!Array.isArray(activeSubs)) return false;
@ -68,6 +73,7 @@ export function AccountCheckoutContainer() {
const activeInternetWarning =
isInternetOrder && hasActiveInternetSubscription ? ACTIVE_INTERNET_SUBSCRIPTION_WARNING : null;
// Payment methods
const {
data: paymentMethods,
isLoading: paymentMethodsLoading,
@ -82,13 +88,13 @@ export function AccountCheckoutContainer() {
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
const hasPaymentMethod = paymentMethodList.length > 0;
const defaultPaymentMethod =
paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null;
const paymentMethodDisplay = defaultPaymentMethod
? buildPaymentMethodDisplay(defaultPaymentMethod)
: null;
// Eligibility
const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder });
const eligibilityValue = eligibilityQuery.data?.eligibility;
const eligibilityStatus = eligibilityQuery.data?.status;
@ -112,6 +118,7 @@ export function AccountCheckoutContainer() {
typeof eligibilityValue === "string" &&
eligibilityValue.trim().length > 0);
// Address
const hasServiceAddress = Boolean(
user?.address?.address1 &&
user?.address?.city &&
@ -120,21 +127,19 @@ export function AccountCheckoutContainer() {
);
const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]);
// Residence card verification
const residenceCardQuery = useResidenceCardVerification();
const submitResidenceCard = useSubmitResidenceCard();
const [residenceFile, setResidenceFile] = useState<File | null>(null);
const residenceFileInputRef = useRef<HTMLInputElement | null>(null);
const residenceStatus = residenceCardQuery.data?.status;
const residenceSubmitted = residenceStatus === "pending" || residenceStatus === "verified";
// Toast handler
const showPaymentToast = useCallback(
(text: string, tone: "info" | "success" | "warning" | "error") => {
if (paymentToastTimeoutRef.current) {
clearTimeout(paymentToastTimeoutRef.current);
paymentToastTimeoutRef.current = null;
}
paymentRefresh.setToast({ visible: true, text, tone });
paymentToastTimeoutRef.current = window.setTimeout(() => {
paymentRefresh.setToast(current => ({ ...current, visible: false }));
@ -165,9 +170,6 @@ export function AccountCheckoutContainer() {
const params = new URLSearchParams(searchParams?.toString() ?? "");
const type = (params.get("type") ?? "").toLowerCase();
params.delete("type");
// Configure flows use `planSku` as the canonical selection.
// Additional params (addons, simType, etc.) may also be present for restore.
const planSku = params.get("planSku")?.trim();
if (!planSku) {
params.delete("planSku");
@ -230,6 +232,27 @@ export function AccountCheckoutContainer() {
}
}, [openingPaymentPortal, showPaymentToast]);
const handleSubmitResidenceCard = useCallback(
(file: File) => {
submitResidenceCard.mutate(file);
},
[submitResidenceCard]
);
// Calculate if form can be submitted
const canSubmit =
addressConfirmed &&
!paymentMethodsLoading &&
hasPaymentMethod &&
residenceSubmitted &&
isEligible &&
!eligibilityLoading &&
!eligibilityPending &&
!eligibilityNotRequested &&
!eligibilityIneligible &&
!eligibilityError;
// Error state - no cart item
if (!cartItem || !orderType) {
const shopHref = pathname.startsWith("/account") ? "/account/services" : "/services";
return (
@ -294,501 +317,38 @@ export function AccountCheckoutContainer() {
/>
</SubCard>
<SubCard
title="Billing & Payment"
icon={<CreditCard className="w-5 h-5 text-primary" />}
right={
<div className="flex items-center gap-2">
{hasPaymentMethod ? <StatusPill label="Verified" variant="success" /> : undefined}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleManagePayment()}
isLoading={openingPaymentPortal}
loadingText="Opening..."
>
{hasPaymentMethod ? "Change" : "Add"}
</Button>
</div>
}
>
{paymentMethodsLoading ? (
<div className="text-sm text-muted-foreground">Checking payment methods...</div>
) : paymentMethodsError ? (
<AlertBanner
variant="warning"
title="Unable to verify payment methods"
size="sm"
elevated
>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => void paymentRefresh.triggerRefresh()}
>
Check Again
</Button>
<Button
type="button"
size="sm"
onClick={() => void handleManagePayment()}
isLoading={openingPaymentPortal}
loadingText="Opening..."
>
Add Payment Method
</Button>
</div>
</AlertBanner>
) : hasPaymentMethod ? (
<div className="space-y-3">
{paymentMethodDisplay ? (
<div className="rounded-xl border border-border bg-card p-4 shadow-[var(--cp-shadow-1)] transition-shadow duration-200 hover:shadow-[var(--cp-shadow-2)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
Default payment method
</p>
<p className="mt-1 text-sm font-semibold text-foreground">
{paymentMethodDisplay.title}
</p>
{paymentMethodDisplay.subtitle ? (
<p className="mt-1 text-xs text-muted-foreground">
{paymentMethodDisplay.subtitle}
</p>
) : null}
</div>
</div>
</div>
) : null}
<p className="text-xs text-muted-foreground">
We securely charge your saved payment method after the order is approved.
</p>
</div>
) : (
<AlertBanner variant="error" title="No payment method on file" size="sm" elevated>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => void paymentRefresh.triggerRefresh()}
>
Check Again
</Button>
<Button
type="button"
size="sm"
onClick={() => void handleManagePayment()}
isLoading={openingPaymentPortal}
loadingText="Opening..."
>
Add Payment Method
</Button>
</div>
</AlertBanner>
)}
</SubCard>
<PaymentMethodSection
isLoading={paymentMethodsLoading}
isError={!!paymentMethodsError}
hasPaymentMethod={hasPaymentMethod}
paymentMethodDisplay={paymentMethodDisplay}
onManagePayment={() => void handleManagePayment()}
onRefresh={() => void paymentRefresh.triggerRefresh()}
isOpeningPortal={openingPaymentPortal}
/>
<SubCard
title="Identity verification"
icon={<ShieldCheck className="w-5 h-5 text-primary" />}
right={
residenceStatus === "verified" ? (
<StatusPill label="Verified" variant="success" />
) : residenceStatus === "pending" ? (
<StatusPill label="Submitted" variant="info" />
) : residenceStatus === "rejected" ? (
<StatusPill label="Action needed" variant="warning" />
) : (
<StatusPill label="Required" variant="warning" />
)
}
>
{residenceCardQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Checking residence card status</div>
) : residenceCardQuery.isError ? (
<AlertBanner
variant="warning"
title="Unable to load verification status"
size="sm"
elevated
>
<Button type="button" size="sm" onClick={() => void residenceCardQuery.refetch()}>
Check again
</Button>
</AlertBanner>
) : residenceStatus === "verified" ? (
<div className="space-y-3">
<AlertBanner variant="success" title="Residence card verified" size="sm" elevated>
Your identity verification is complete.
</AlertBanner>
{residenceCardQuery.data?.submittedAt || residenceCardQuery.data?.reviewedAt ? (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Verification status
</div>
<div className="mt-1 text-xs text-muted-foreground space-y-0.5">
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
<div>
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
</div>
) : null}
{formatDateTime(residenceCardQuery.data?.reviewedAt) ? (
<div>Reviewed: {formatDateTime(residenceCardQuery.data?.reviewedAt)}</div>
) : null}
</div>
</div>
) : null}
<details className="rounded-xl border border-border bg-card p-4">
<summary className="cursor-pointer select-none text-sm font-semibold text-foreground">
Replace residence card
</summary>
<div className="pt-3 space-y-3">
<p className="text-xs text-muted-foreground">
Replacing the file restarts the verification process.
</p>
<input
ref={residenceFileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => setResidenceFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
/>
{residenceFile ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
Selected file
</div>
<div className="text-sm font-medium text-foreground truncate">
{residenceFile.name}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
}}
>
Change
</Button>
</div>
) : null}
<div className="flex items-center justify-end">
<Button
type="button"
size="sm"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: () => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
},
});
}}
>
Submit replacement
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
</details>
</div>
) : residenceStatus === "pending" ? (
<div className="space-y-3">
<AlertBanner variant="info" title="Residence card submitted" size="sm" elevated>
Well verify your residence card before activating SIM service.
</AlertBanner>
{residenceCardQuery.data?.submittedAt ? (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Submission status
</div>
<div className="mt-1 text-xs text-muted-foreground">
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
</div>
</div>
) : null}
<details className="rounded-xl border border-border bg-card p-4">
<summary className="cursor-pointer select-none text-sm font-semibold text-foreground">
Replace residence card
</summary>
<div className="pt-3 space-y-3">
<p className="text-xs text-muted-foreground">
If you uploaded the wrong file, you can replace it. This restarts the
review.
</p>
<input
ref={residenceFileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => setResidenceFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
/>
{residenceFile ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
Selected file
</div>
<div className="text-sm font-medium text-foreground truncate">
{residenceFile.name}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
}}
>
Change
</Button>
</div>
) : null}
<div className="flex items-center justify-end">
<Button
type="button"
size="sm"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: () => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
},
});
}}
>
Submit replacement
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
</details>
</div>
) : (
<AlertBanner
variant={residenceStatus === "rejected" ? "warning" : "info"}
title={
residenceStatus === "rejected"
? "ID verification rejected"
: "Submit your residence card"
}
size="sm"
elevated
>
<div className="space-y-3">
{residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
<div className="text-sm text-foreground/80">
<div className="font-medium text-foreground">Rejection note</div>
<div>{residenceCardQuery.data.reviewerNotes}</div>
</div>
) : residenceStatus === "rejected" ? (
<p className="text-sm text-foreground/80">
Your document couldnt be approved. Please upload a new file to continue.
</p>
) : null}
<p className="text-sm text-foreground/80">
Upload a JPG, PNG, or PDF (max 5MB). Well verify it before activating SIM
service.
</p>
<div className="space-y-2">
<input
ref={residenceFileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => setResidenceFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
/>
{residenceFile ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
Selected file
</div>
<div className="text-sm font-medium text-foreground truncate">
{residenceFile.name}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
}}
>
Change
</Button>
</div>
) : null}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<Button
type="button"
size="sm"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: () => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
},
});
}}
className="sm:ml-auto whitespace-nowrap"
>
Submit for review
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
</AlertBanner>
)}
</SubCard>
<IdentityVerificationSection
isLoading={residenceCardQuery.isLoading}
isError={residenceCardQuery.isError}
status={residenceStatus}
data={residenceCardQuery.data}
onRefetch={() => void residenceCardQuery.refetch()}
onSubmitFile={handleSubmitResidenceCard}
isSubmitting={submitResidenceCard.isPending}
submitError={submitResidenceCard.error ?? undefined}
formatDateTime={formatDateTime}
/>
</div>
</div>
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 text-center shadow-[var(--cp-shadow-1)]">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm border border-primary/20">
<ShieldCheck className="w-8 h-8 text-primary" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">Review & Submit</h2>
<p className="text-muted-foreground mb-4 max-w-xl mx-auto">
Youre almost done. Confirm your details above, then submit your order. Well review and
notify you when everything is ready.
</p>
{submitError ? (
<div className="pb-4">
<AlertBanner variant="error" title="Unable to submit order" elevated>
{submitError}
</AlertBanner>
</div>
) : null}
<div className="bg-muted/50 rounded-lg p-4 border border-border text-left max-w-2xl mx-auto">
<h3 className="font-semibold text-foreground mb-2">What to expect</h3>
<div className="text-sm text-muted-foreground space-y-1">
<p> Our team reviews your order and schedules setup if needed</p>
<p> We may contact you to confirm details or availability</p>
<p> We verify your residence card before service activation</p>
<p> We only charge your card after the order is approved</p>
<p> Youll receive confirmation and next steps by email</p>
</div>
</div>
<div className="mt-4 bg-card rounded-lg p-4 border border-border max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
<div className="flex justify-between items-center">
<span className="font-medium text-muted-foreground">Estimated Total</span>
<div className="text-right">
<div className="text-xl font-bold text-foreground">
¥{cartItem.pricing.monthlyTotal.toLocaleString()}/mo
</div>
{cartItem.pricing.oneTimeTotal > 0 && (
<div className="text-sm text-warning font-medium">
+ ¥{cartItem.pricing.oneTimeTotal.toLocaleString()} one-time
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex gap-4">
<Button
type="button"
variant="ghost"
className="flex-1 py-4 text-muted-foreground hover:text-foreground"
onClick={navigateBackToConfigure}
>
Back to Configuration
</Button>
<Button
type="button"
className="flex-1 py-4 text-lg"
onClick={() => void handleSubmitOrder()}
disabled={
submitting ||
!addressConfirmed ||
paymentMethodsLoading ||
!hasPaymentMethod ||
!residenceSubmitted ||
!isEligible ||
eligibilityLoading ||
eligibilityPending ||
eligibilityNotRequested ||
eligibilityIneligible ||
eligibilityError
}
isLoading={submitting}
loadingText="Submitting…"
>
Submit order
</Button>
</div>
<OrderSubmitSection
pricing={cartItem.pricing}
submitError={submitError}
isSubmitting={submitting}
canSubmit={canSubmit}
onSubmit={() => void handleSubmitOrder()}
onBack={navigateBackToConfigure}
/>
</div>
</PageLayout>
);

View File

@ -0,0 +1,259 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { StatusPill } from "@/components/atoms/status-pill";
import { ResidenceCardUploadInput } from "./ResidenceCardUploadInput";
type VerificationStatus = "verified" | "pending" | "rejected" | "not_submitted" | undefined;
interface VerificationData {
submittedAt?: string | null;
reviewedAt?: string | null;
reviewerNotes?: string | null;
}
interface IdentityVerificationSectionProps {
isLoading: boolean;
isError: boolean;
status: VerificationStatus;
data?: VerificationData;
onRefetch: () => void;
onSubmitFile: (file: File) => void;
isSubmitting: boolean;
submitError?: Error | null;
formatDateTime: (iso?: string | null) => string | null;
}
export function IdentityVerificationSection({
isLoading,
isError,
status,
data,
onRefetch,
onSubmitFile,
isSubmitting,
submitError,
formatDateTime,
}: IdentityVerificationSectionProps) {
const getStatusPill = () => {
switch (status) {
case "verified":
return <StatusPill label="Verified" variant="success" />;
case "pending":
return <StatusPill label="Submitted" variant="info" />;
case "rejected":
return <StatusPill label="Action needed" variant="warning" />;
default:
return <StatusPill label="Required" variant="warning" />;
}
};
return (
<SubCard
title="Identity verification"
icon={<ShieldCheck className="w-5 h-5 text-primary" />}
right={getStatusPill()}
>
{isLoading ? (
<div className="text-sm text-muted-foreground">Checking residence card status...</div>
) : isError ? (
<AlertBanner
variant="warning"
title="Unable to load verification status"
size="sm"
elevated
>
<Button type="button" size="sm" onClick={onRefetch}>
Check again
</Button>
</AlertBanner>
) : status === "verified" ? (
<VerifiedContent
data={data}
formatDateTime={formatDateTime}
onSubmitFile={onSubmitFile}
isSubmitting={isSubmitting}
submitError={submitError}
/>
) : status === "pending" ? (
<PendingContent
data={data}
formatDateTime={formatDateTime}
onSubmitFile={onSubmitFile}
isSubmitting={isSubmitting}
submitError={submitError}
/>
) : (
<NotSubmittedContent
status={status}
reviewerNotes={data?.reviewerNotes}
onSubmitFile={onSubmitFile}
isSubmitting={isSubmitting}
submitError={submitError}
/>
)}
</SubCard>
);
}
interface VerifiedContentProps {
data?: VerificationData;
formatDateTime: (iso?: string | null) => string | null;
onSubmitFile: (file: File) => void;
isSubmitting: boolean;
submitError?: Error | null;
}
function VerifiedContent({
data,
formatDateTime,
onSubmitFile,
isSubmitting,
submitError,
}: VerifiedContentProps) {
return (
<div className="space-y-3">
<AlertBanner variant="success" title="Residence card verified" size="sm" elevated>
Your identity verification is complete.
</AlertBanner>
{(data?.submittedAt || data?.reviewedAt) && (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Verification status
</div>
<div className="mt-1 text-xs text-muted-foreground space-y-0.5">
{formatDateTime(data?.submittedAt) && (
<div>Submitted: {formatDateTime(data?.submittedAt)}</div>
)}
{formatDateTime(data?.reviewedAt) && (
<div>Reviewed: {formatDateTime(data?.reviewedAt)}</div>
)}
</div>
</div>
)}
<details className="rounded-xl border border-border bg-card p-4">
<summary className="cursor-pointer select-none text-sm font-semibold text-foreground">
Replace residence card
</summary>
<div className="pt-3">
<ResidenceCardUploadInput
onSubmit={onSubmitFile}
isPending={isSubmitting}
isError={!!submitError}
error={submitError}
submitLabel="Submit replacement"
description="Replacing the file restarts the verification process."
/>
</div>
</details>
</div>
);
}
interface PendingContentProps {
data?: VerificationData;
formatDateTime: (iso?: string | null) => string | null;
onSubmitFile: (file: File) => void;
isSubmitting: boolean;
submitError?: Error | null;
}
function PendingContent({
data,
formatDateTime,
onSubmitFile,
isSubmitting,
submitError,
}: PendingContentProps) {
return (
<div className="space-y-3">
<AlertBanner variant="info" title="Residence card submitted" size="sm" elevated>
We'll verify your residence card before activating SIM service.
</AlertBanner>
{data?.submittedAt && (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Submission status
</div>
<div className="mt-1 text-xs text-muted-foreground">
Submitted: {formatDateTime(data?.submittedAt)}
</div>
</div>
)}
<details className="rounded-xl border border-border bg-card p-4">
<summary className="cursor-pointer select-none text-sm font-semibold text-foreground">
Replace residence card
</summary>
<div className="pt-3">
<ResidenceCardUploadInput
onSubmit={onSubmitFile}
isPending={isSubmitting}
isError={!!submitError}
error={submitError}
submitLabel="Submit replacement"
description="If you uploaded the wrong file, you can replace it. This restarts the review."
/>
</div>
</details>
</div>
);
}
interface NotSubmittedContentProps {
status: VerificationStatus;
reviewerNotes?: string | null;
onSubmitFile: (file: File) => void;
isSubmitting: boolean;
submitError?: Error | null;
}
function NotSubmittedContent({
status,
reviewerNotes,
onSubmitFile,
isSubmitting,
submitError,
}: NotSubmittedContentProps) {
const isRejected = status === "rejected";
return (
<AlertBanner
variant={isRejected ? "warning" : "info"}
title={isRejected ? "ID verification rejected" : "Submit your residence card"}
size="sm"
elevated
>
<div className="space-y-3">
{isRejected && reviewerNotes ? (
<div className="text-sm text-foreground/80">
<div className="font-medium text-foreground">Rejection note</div>
<div>{reviewerNotes}</div>
</div>
) : isRejected ? (
<p className="text-sm text-foreground/80">
Your document couldn't be approved. Please upload a new file to continue.
</p>
) : null}
<p className="text-sm text-foreground/80">
Upload a JPG, PNG, or PDF (max 5MB). We'll verify it before activating SIM service.
</p>
<ResidenceCardUploadInput
onSubmit={onSubmitFile}
isPending={isSubmitting}
isError={!!submitError}
error={submitError}
submitLabel="Submit for review"
/>
</div>
</AlertBanner>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
interface CartPricing {
monthlyTotal: number;
oneTimeTotal: number;
}
interface OrderSubmitSectionProps {
pricing: CartPricing;
submitError: string | null;
isSubmitting: boolean;
canSubmit: boolean;
onSubmit: () => void;
onBack: () => void;
}
export function OrderSubmitSection({
pricing,
submitError,
isSubmitting,
canSubmit,
onSubmit,
onBack,
}: OrderSubmitSectionProps) {
return (
<>
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 text-center shadow-[var(--cp-shadow-1)]">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm border border-primary/20">
<ShieldCheck className="w-8 h-8 text-primary" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">Review & Submit</h2>
<p className="text-muted-foreground mb-4 max-w-xl mx-auto">
You're almost done. Confirm your details above, then submit your order. We'll review and
notify you when everything is ready.
</p>
{submitError && (
<div className="pb-4">
<AlertBanner variant="error" title="Unable to submit order" elevated>
{submitError}
</AlertBanner>
</div>
)}
<div className="bg-muted/50 rounded-lg p-4 border border-border text-left max-w-2xl mx-auto">
<h3 className="font-semibold text-foreground mb-2">What to expect</h3>
<div className="text-sm text-muted-foreground space-y-1">
<p> Our team reviews your order and schedules setup if needed</p>
<p> We may contact you to confirm details or availability</p>
<p> We verify your residence card before service activation</p>
<p> We only charge your card after the order is approved</p>
<p> You'll receive confirmation and next steps by email</p>
</div>
</div>
<div className="mt-4 bg-card rounded-lg p-4 border border-border max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
<div className="flex justify-between items-center">
<span className="font-medium text-muted-foreground">Estimated Total</span>
<div className="text-right">
<div className="text-xl font-bold text-foreground">
¥{pricing.monthlyTotal.toLocaleString()}/mo
</div>
{pricing.oneTimeTotal > 0 && (
<div className="text-sm text-warning font-medium">
+ ¥{pricing.oneTimeTotal.toLocaleString()} one-time
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex gap-4">
<Button
type="button"
variant="ghost"
className="flex-1 py-4 text-muted-foreground hover:text-foreground"
onClick={onBack}
>
Back to Configuration
</Button>
<Button
type="button"
className="flex-1 py-4 text-lg"
onClick={onSubmit}
disabled={!canSubmit || isSubmitting}
isLoading={isSubmitting}
loadingText="Submitting..."
>
Submit order
</Button>
</div>
</>
);
}

View File

@ -0,0 +1,117 @@
"use client";
import { CreditCard } from "lucide-react";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { StatusPill } from "@/components/atoms/status-pill";
interface PaymentMethodDisplay {
title: string;
subtitle?: string;
}
interface PaymentMethodSectionProps {
isLoading: boolean;
isError: boolean;
hasPaymentMethod: boolean;
paymentMethodDisplay: PaymentMethodDisplay | null;
onManagePayment: () => void;
onRefresh: () => void;
isOpeningPortal: boolean;
}
export function PaymentMethodSection({
isLoading,
isError,
hasPaymentMethod,
paymentMethodDisplay,
onManagePayment,
onRefresh,
isOpeningPortal,
}: PaymentMethodSectionProps) {
return (
<SubCard
title="Billing & Payment"
icon={<CreditCard className="w-5 h-5 text-primary" />}
right={
<div className="flex items-center gap-2">
{hasPaymentMethod && <StatusPill label="Verified" variant="success" />}
<Button
type="button"
size="sm"
variant="outline"
onClick={onManagePayment}
isLoading={isOpeningPortal}
loadingText="Opening..."
>
{hasPaymentMethod ? "Change" : "Add"}
</Button>
</div>
}
>
{isLoading ? (
<div className="text-sm text-muted-foreground">Checking payment methods...</div>
) : isError ? (
<AlertBanner variant="warning" title="Unable to verify payment methods" size="sm" elevated>
<div className="flex items-center gap-2">
<Button type="button" size="sm" onClick={onRefresh}>
Check Again
</Button>
<Button
type="button"
size="sm"
onClick={onManagePayment}
isLoading={isOpeningPortal}
loadingText="Opening..."
>
Add Payment Method
</Button>
</div>
</AlertBanner>
) : hasPaymentMethod ? (
<div className="space-y-3">
{paymentMethodDisplay && (
<div className="rounded-xl border border-border bg-card p-4 shadow-[var(--cp-shadow-1)] transition-shadow duration-200 hover:shadow-[var(--cp-shadow-2)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
Default payment method
</p>
<p className="mt-1 text-sm font-semibold text-foreground">
{paymentMethodDisplay.title}
</p>
{paymentMethodDisplay.subtitle && (
<p className="mt-1 text-xs text-muted-foreground">
{paymentMethodDisplay.subtitle}
</p>
)}
</div>
</div>
</div>
)}
<p className="text-xs text-muted-foreground">
We securely charge your saved payment method after the order is approved.
</p>
</div>
) : (
<AlertBanner variant="error" title="No payment method on file" size="sm" elevated>
<div className="flex items-center gap-2">
<Button type="button" size="sm" onClick={onRefresh}>
Check Again
</Button>
<Button
type="button"
size="sm"
onClick={onManagePayment}
isLoading={isOpeningPortal}
loadingText="Opening..."
>
Add Payment Method
</Button>
</div>
</AlertBanner>
)}
</SubCard>
);
}

View File

@ -0,0 +1,83 @@
"use client";
import { useRef, useState } from "react";
import { Button } from "@/components/atoms/button";
interface ResidenceCardUploadInputProps {
onSubmit: (file: File) => void;
isPending: boolean;
isError: boolean;
error?: Error | null;
submitLabel?: string;
description?: string;
}
export function ResidenceCardUploadInput({
onSubmit,
isPending,
isError,
error,
submitLabel = "Submit for review",
description,
}: ResidenceCardUploadInputProps) {
const [file, setFile] = useState<File | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const handleClear = () => {
setFile(null);
if (inputRef.current) {
inputRef.current.value = "";
}
};
const handleSubmit = () => {
if (!file) return;
onSubmit(file);
handleClear();
};
return (
<div className="space-y-3">
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<input
ref={inputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => setFile(e.target.files?.[0] ?? null)}
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
/>
{file && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">Selected file</div>
<div className="text-sm font-medium text-foreground truncate">{file.name}</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleClear}>
Change
</Button>
</div>
)}
<div className="flex items-center justify-end">
<Button
type="button"
size="sm"
disabled={!file || isPending}
isLoading={isPending}
loadingText="Uploading..."
onClick={handleSubmit}
>
{submitLabel}
</Button>
</div>
{isError && error && (
<div className="text-sm text-destructive">
{error instanceof Error ? error.message : "Failed to submit residence card."}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,4 @@
export { ResidenceCardUploadInput } from "./ResidenceCardUploadInput";
export { PaymentMethodSection } from "./PaymentMethodSection";
export { IdentityVerificationSection } from "./IdentityVerificationSection";
export { OrderSubmitSection } from "./OrderSubmitSection";