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 { SalesforceOrderFieldConfigModule } from "./config/salesforce-order-field-config.module.js";
|
||||||
import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js";
|
import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard.js";
|
||||||
import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-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({
|
@Module({
|
||||||
imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule],
|
imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule],
|
||||||
@ -19,6 +22,11 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
|||||||
SalesforceAccountService,
|
SalesforceAccountService,
|
||||||
SalesforceOrderService,
|
SalesforceOrderService,
|
||||||
SalesforceCaseService,
|
SalesforceCaseService,
|
||||||
|
// Opportunity decomposed services
|
||||||
|
OpportunityQueryService,
|
||||||
|
OpportunityCancellationService,
|
||||||
|
OpportunityMutationService,
|
||||||
|
// Opportunity facade (depends on decomposed services)
|
||||||
SalesforceOpportunityService,
|
SalesforceOpportunityService,
|
||||||
OpportunityResolutionService,
|
OpportunityResolutionService,
|
||||||
SalesforceService,
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
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 { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { InlineToast } from "@/components/atoms/inline-toast";
|
import { InlineToast } from "@/components/atoms/inline-toast";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
|
||||||
import { AddressConfirmation } from "@/features/services/components/base/AddressConfirmation";
|
import { AddressConfirmation } from "@/features/services/components/base/AddressConfirmation";
|
||||||
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
|
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
|
||||||
import { ordersService } from "@/features/orders/api/orders.api";
|
import { ordersService } from "@/features/orders/api/orders.api";
|
||||||
@ -33,6 +32,11 @@ import {
|
|||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils";
|
import { buildPaymentMethodDisplay, formatAddressLabel } from "@/shared/utils";
|
||||||
import { CheckoutStatusBanners } from "./CheckoutStatusBanners";
|
import { CheckoutStatusBanners } from "./CheckoutStatusBanners";
|
||||||
|
import {
|
||||||
|
PaymentMethodSection,
|
||||||
|
IdentityVerificationSection,
|
||||||
|
OrderSubmitSection,
|
||||||
|
} from "./checkout-sections";
|
||||||
|
|
||||||
export function AccountCheckoutContainer() {
|
export function AccountCheckoutContainer() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -54,6 +58,7 @@ export function AccountCheckoutContainer() {
|
|||||||
|
|
||||||
const isInternetOrder = orderType === ORDER_TYPE.INTERNET;
|
const isInternetOrder = orderType === ORDER_TYPE.INTERNET;
|
||||||
|
|
||||||
|
// Active subscriptions check
|
||||||
const { data: activeSubs } = useActiveSubscriptions();
|
const { data: activeSubs } = useActiveSubscriptions();
|
||||||
const hasActiveInternetSubscription = useMemo(() => {
|
const hasActiveInternetSubscription = useMemo(() => {
|
||||||
if (!Array.isArray(activeSubs)) return false;
|
if (!Array.isArray(activeSubs)) return false;
|
||||||
@ -68,6 +73,7 @@ export function AccountCheckoutContainer() {
|
|||||||
const activeInternetWarning =
|
const activeInternetWarning =
|
||||||
isInternetOrder && hasActiveInternetSubscription ? ACTIVE_INTERNET_SUBSCRIPTION_WARNING : null;
|
isInternetOrder && hasActiveInternetSubscription ? ACTIVE_INTERNET_SUBSCRIPTION_WARNING : null;
|
||||||
|
|
||||||
|
// Payment methods
|
||||||
const {
|
const {
|
||||||
data: paymentMethods,
|
data: paymentMethods,
|
||||||
isLoading: paymentMethodsLoading,
|
isLoading: paymentMethodsLoading,
|
||||||
@ -82,13 +88,13 @@ export function AccountCheckoutContainer() {
|
|||||||
|
|
||||||
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
|
const paymentMethodList = paymentMethods?.paymentMethods ?? [];
|
||||||
const hasPaymentMethod = paymentMethodList.length > 0;
|
const hasPaymentMethod = paymentMethodList.length > 0;
|
||||||
|
|
||||||
const defaultPaymentMethod =
|
const defaultPaymentMethod =
|
||||||
paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null;
|
paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null;
|
||||||
const paymentMethodDisplay = defaultPaymentMethod
|
const paymentMethodDisplay = defaultPaymentMethod
|
||||||
? buildPaymentMethodDisplay(defaultPaymentMethod)
|
? buildPaymentMethodDisplay(defaultPaymentMethod)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Eligibility
|
||||||
const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder });
|
const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder });
|
||||||
const eligibilityValue = eligibilityQuery.data?.eligibility;
|
const eligibilityValue = eligibilityQuery.data?.eligibility;
|
||||||
const eligibilityStatus = eligibilityQuery.data?.status;
|
const eligibilityStatus = eligibilityQuery.data?.status;
|
||||||
@ -112,6 +118,7 @@ export function AccountCheckoutContainer() {
|
|||||||
typeof eligibilityValue === "string" &&
|
typeof eligibilityValue === "string" &&
|
||||||
eligibilityValue.trim().length > 0);
|
eligibilityValue.trim().length > 0);
|
||||||
|
|
||||||
|
// Address
|
||||||
const hasServiceAddress = Boolean(
|
const hasServiceAddress = Boolean(
|
||||||
user?.address?.address1 &&
|
user?.address?.address1 &&
|
||||||
user?.address?.city &&
|
user?.address?.city &&
|
||||||
@ -120,21 +127,19 @@ export function AccountCheckoutContainer() {
|
|||||||
);
|
);
|
||||||
const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]);
|
const addressLabel = useMemo(() => formatAddressLabel(user?.address), [user?.address]);
|
||||||
|
|
||||||
|
// Residence card verification
|
||||||
const residenceCardQuery = useResidenceCardVerification();
|
const residenceCardQuery = useResidenceCardVerification();
|
||||||
const submitResidenceCard = useSubmitResidenceCard();
|
const submitResidenceCard = useSubmitResidenceCard();
|
||||||
const [residenceFile, setResidenceFile] = useState<File | null>(null);
|
|
||||||
const residenceFileInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const residenceStatus = residenceCardQuery.data?.status;
|
const residenceStatus = residenceCardQuery.data?.status;
|
||||||
const residenceSubmitted = residenceStatus === "pending" || residenceStatus === "verified";
|
const residenceSubmitted = residenceStatus === "pending" || residenceStatus === "verified";
|
||||||
|
|
||||||
|
// Toast handler
|
||||||
const showPaymentToast = useCallback(
|
const showPaymentToast = useCallback(
|
||||||
(text: string, tone: "info" | "success" | "warning" | "error") => {
|
(text: string, tone: "info" | "success" | "warning" | "error") => {
|
||||||
if (paymentToastTimeoutRef.current) {
|
if (paymentToastTimeoutRef.current) {
|
||||||
clearTimeout(paymentToastTimeoutRef.current);
|
clearTimeout(paymentToastTimeoutRef.current);
|
||||||
paymentToastTimeoutRef.current = null;
|
paymentToastTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
paymentRefresh.setToast({ visible: true, text, tone });
|
paymentRefresh.setToast({ visible: true, text, tone });
|
||||||
paymentToastTimeoutRef.current = window.setTimeout(() => {
|
paymentToastTimeoutRef.current = window.setTimeout(() => {
|
||||||
paymentRefresh.setToast(current => ({ ...current, visible: false }));
|
paymentRefresh.setToast(current => ({ ...current, visible: false }));
|
||||||
@ -165,9 +170,6 @@ export function AccountCheckoutContainer() {
|
|||||||
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
const params = new URLSearchParams(searchParams?.toString() ?? "");
|
||||||
const type = (params.get("type") ?? "").toLowerCase();
|
const type = (params.get("type") ?? "").toLowerCase();
|
||||||
params.delete("type");
|
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();
|
const planSku = params.get("planSku")?.trim();
|
||||||
if (!planSku) {
|
if (!planSku) {
|
||||||
params.delete("planSku");
|
params.delete("planSku");
|
||||||
@ -230,6 +232,27 @@ export function AccountCheckoutContainer() {
|
|||||||
}
|
}
|
||||||
}, [openingPaymentPortal, showPaymentToast]);
|
}, [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) {
|
if (!cartItem || !orderType) {
|
||||||
const shopHref = pathname.startsWith("/account") ? "/account/services" : "/services";
|
const shopHref = pathname.startsWith("/account") ? "/account/services" : "/services";
|
||||||
return (
|
return (
|
||||||
@ -294,501 +317,38 @@ export function AccountCheckoutContainer() {
|
|||||||
/>
|
/>
|
||||||
</SubCard>
|
</SubCard>
|
||||||
|
|
||||||
<SubCard
|
<PaymentMethodSection
|
||||||
title="Billing & Payment"
|
isLoading={paymentMethodsLoading}
|
||||||
icon={<CreditCard className="w-5 h-5 text-primary" />}
|
isError={!!paymentMethodsError}
|
||||||
right={
|
hasPaymentMethod={hasPaymentMethod}
|
||||||
<div className="flex items-center gap-2">
|
paymentMethodDisplay={paymentMethodDisplay}
|
||||||
{hasPaymentMethod ? <StatusPill label="Verified" variant="success" /> : undefined}
|
onManagePayment={() => void handleManagePayment()}
|
||||||
<Button
|
onRefresh={() => void paymentRefresh.triggerRefresh()}
|
||||||
type="button"
|
isOpeningPortal={openingPaymentPortal}
|
||||||
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
|
<IdentityVerificationSection
|
||||||
title="Identity verification"
|
isLoading={residenceCardQuery.isLoading}
|
||||||
icon={<ShieldCheck className="w-5 h-5 text-primary" />}
|
isError={residenceCardQuery.isError}
|
||||||
right={
|
status={residenceStatus}
|
||||||
residenceStatus === "verified" ? (
|
data={residenceCardQuery.data}
|
||||||
<StatusPill label="Verified" variant="success" />
|
onRefetch={() => void residenceCardQuery.refetch()}
|
||||||
) : residenceStatus === "pending" ? (
|
onSubmitFile={handleSubmitResidenceCard}
|
||||||
<StatusPill label="Submitted" variant="info" />
|
isSubmitting={submitResidenceCard.isPending}
|
||||||
) : residenceStatus === "rejected" ? (
|
submitError={submitResidenceCard.error ?? undefined}
|
||||||
<StatusPill label="Action needed" variant="warning" />
|
formatDateTime={formatDateTime}
|
||||||
) : (
|
/>
|
||||||
<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>
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 text-center shadow-[var(--cp-shadow-1)]">
|
<OrderSubmitSection
|
||||||
<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">
|
pricing={cartItem.pricing}
|
||||||
<ShieldCheck className="w-8 h-8 text-primary" />
|
submitError={submitError}
|
||||||
</div>
|
isSubmitting={submitting}
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">Review & Submit</h2>
|
canSubmit={canSubmit}
|
||||||
<p className="text-muted-foreground mb-4 max-w-xl mx-auto">
|
onSubmit={() => void handleSubmitOrder()}
|
||||||
You’re almost done. Confirm your details above, then submit your order. We’ll review and
|
onBack={navigateBackToConfigure}
|
||||||
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>
|
</div>
|
||||||
</PageLayout>
|
</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