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:
parent
f447ba1800
commit
b49c94994d
@ -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,
|
||||
|
||||
@ -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";
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
<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"
|
||||
<PaymentMethodSection
|
||||
isLoading={paymentMethodsLoading}
|
||||
isError={!!paymentMethodsError}
|
||||
hasPaymentMethod={hasPaymentMethod}
|
||||
paymentMethodDisplay={paymentMethodDisplay}
|
||||
onManagePayment={() => void handleManagePayment()}
|
||||
onRefresh={() => void paymentRefresh.triggerRefresh()}
|
||||
isOpeningPortal={openingPaymentPortal}
|
||||
/>
|
||||
|
||||
{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>
|
||||
We’ll 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"
|
||||
<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}
|
||||
/>
|
||||
|
||||
{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 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>
|
||||
|
||||
<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"
|
||||
<OrderSubmitSection
|
||||
pricing={cartItem.pricing}
|
||||
submitError={submitError}
|
||||
isSubmitting={submitting}
|
||||
canSubmit={canSubmit}
|
||||
onSubmit={() => void handleSubmitOrder()}
|
||||
onBack={navigateBackToConfigure}
|
||||
/>
|
||||
|
||||
{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>
|
||||
</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">
|
||||
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>
|
||||
) : 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>• 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">
|
||||
¥{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>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export { ResidenceCardUploadInput } from "./ResidenceCardUploadInput";
|
||||
export { PaymentMethodSection } from "./PaymentMethodSection";
|
||||
export { IdentityVerificationSection } from "./IdentityVerificationSection";
|
||||
export { OrderSubmitSection } from "./OrderSubmitSection";
|
||||
Loading…
x
Reference in New Issue
Block a user