Enhance Signup Workflow and Update Catalog Components
- Refactored the SignupWorkflowService to throw a DomainHttpException for legacy account conflicts, improving error handling. - Updated the SignupForm component to include initialEmail and showFooterLinks props, enhancing user experience during account creation. - Improved the AccountStep in the SignupForm to allow users to add optional details, such as date of birth and gender, for a more personalized signup process. - Enhanced the PasswordStep to include terms acceptance and marketing consent options, ensuring compliance and user engagement. - Updated various catalog views to improve layout and user guidance, streamlining the onboarding process for new users.
This commit is contained in:
parent
f5cde96027
commit
d9734b0c82
@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* Opportunity Field Map Configuration
|
||||||
|
*
|
||||||
|
* Maps logical field names to Salesforce API field names.
|
||||||
|
* Uses existing Salesforce fields where available.
|
||||||
|
*
|
||||||
|
* @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for setup instructions
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Standard Salesforce Opportunity Fields
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const OPPORTUNITY_STANDARD_FIELDS = {
|
||||||
|
/** Salesforce Opportunity ID */
|
||||||
|
id: "Id",
|
||||||
|
|
||||||
|
/** Opportunity name (auto-generated: "{Account Name} - {Product Type} Inquiry") */
|
||||||
|
name: "Name",
|
||||||
|
|
||||||
|
/** Related Account ID */
|
||||||
|
accountId: "AccountId",
|
||||||
|
|
||||||
|
/** Account name (via relationship query) */
|
||||||
|
accountName: "Account.Name",
|
||||||
|
|
||||||
|
/** Current stage in the lifecycle */
|
||||||
|
stage: "StageName",
|
||||||
|
|
||||||
|
/** Expected close date */
|
||||||
|
closeDate: "CloseDate",
|
||||||
|
|
||||||
|
/** Whether the Opportunity is closed (read-only, derived from stage) */
|
||||||
|
isClosed: "IsClosed",
|
||||||
|
|
||||||
|
/** Whether the Opportunity is won (read-only, derived from stage) */
|
||||||
|
isWon: "IsWon",
|
||||||
|
|
||||||
|
/** Opportunity description */
|
||||||
|
description: "Description",
|
||||||
|
|
||||||
|
/** Created date */
|
||||||
|
createdDate: "CreatedDate",
|
||||||
|
|
||||||
|
/** Last modified date */
|
||||||
|
lastModifiedDate: "LastModifiedDate",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Existing Custom Opportunity Fields
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These fields already exist in Salesforce
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_EXISTING_CUSTOM_FIELDS = {
|
||||||
|
// ---- Application Stage ----
|
||||||
|
|
||||||
|
/** Application process stage (INTRO-1, N/A, etc.) */
|
||||||
|
applicationStage: "Application_Stage__c",
|
||||||
|
|
||||||
|
// ---- Cancellation Fields (existing) ----
|
||||||
|
|
||||||
|
/** Scheduled cancellation date/time (end of month) */
|
||||||
|
scheduledCancellationDate: "ScheduledCancellationDateAndTime__c",
|
||||||
|
|
||||||
|
/** Cancellation notice status: 有 (received), 未 (not yet), 不要 (not required), 移転 (transfer) */
|
||||||
|
cancellationNotice: "CancellationNotice__c",
|
||||||
|
|
||||||
|
/** Line return status for rental equipment */
|
||||||
|
lineReturnStatus: "LineReturn__c",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Existing Custom Fields for Product Type
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommodityType field already exists in Salesforce
|
||||||
|
* Used to track product/service type
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_COMMODITY_FIELD = {
|
||||||
|
/** Product/commodity type (existing field) */
|
||||||
|
commodityType: "CommodityType",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// New Custom Fields (to be created in Salesforce)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New custom fields to be created in Salesforce.
|
||||||
|
*
|
||||||
|
* NOTE:
|
||||||
|
* - CommodityType already exists - no need to create Product_Type__c
|
||||||
|
* - Cases link TO Opportunity via Case.OpportunityId (no custom field on Opp)
|
||||||
|
* - Orders link TO Opportunity via Order.OpportunityId (standard field)
|
||||||
|
* - Alternative email and cancellation comments go on Case, not Opportunity
|
||||||
|
*
|
||||||
|
* TODO: Confirm with Salesforce admin and update API names after creation
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_NEW_CUSTOM_FIELDS = {
|
||||||
|
// ---- Source Field (to be created) ----
|
||||||
|
|
||||||
|
/** Source of the Opportunity creation */
|
||||||
|
source: "Portal_Source__c",
|
||||||
|
|
||||||
|
// ---- Integration Fields (to be created) ----
|
||||||
|
|
||||||
|
/** WHMCS Service ID (populated after provisioning) */
|
||||||
|
whmcsServiceId: "WHMCS_Service_ID__c",
|
||||||
|
|
||||||
|
// NOTE: Cancellation comments and alternative email go on the Cancellation Case,
|
||||||
|
// not on the Opportunity. This keeps Opportunity clean and Case contains all details.
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Combined Field Map
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete Opportunity field map for portal operations
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_FIELD_MAP = {
|
||||||
|
...OPPORTUNITY_STANDARD_FIELDS,
|
||||||
|
...OPPORTUNITY_EXISTING_CUSTOM_FIELDS,
|
||||||
|
...OPPORTUNITY_COMMODITY_FIELD,
|
||||||
|
...OPPORTUNITY_NEW_CUSTOM_FIELDS,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OpportunityFieldMap = typeof OPPORTUNITY_FIELD_MAP;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Query Field Sets
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields to select when querying Opportunities for matching
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_MATCH_QUERY_FIELDS = [
|
||||||
|
OPPORTUNITY_FIELD_MAP.id,
|
||||||
|
OPPORTUNITY_FIELD_MAP.name,
|
||||||
|
OPPORTUNITY_FIELD_MAP.accountId,
|
||||||
|
OPPORTUNITY_FIELD_MAP.stage,
|
||||||
|
OPPORTUNITY_FIELD_MAP.closeDate,
|
||||||
|
OPPORTUNITY_FIELD_MAP.isClosed,
|
||||||
|
OPPORTUNITY_FIELD_MAP.applicationStage,
|
||||||
|
OPPORTUNITY_FIELD_MAP.commodityType,
|
||||||
|
OPPORTUNITY_FIELD_MAP.source,
|
||||||
|
OPPORTUNITY_FIELD_MAP.createdDate,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields to select when querying full Opportunity details
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_DETAIL_QUERY_FIELDS = [
|
||||||
|
...OPPORTUNITY_MATCH_QUERY_FIELDS,
|
||||||
|
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
||||||
|
OPPORTUNITY_FIELD_MAP.scheduledCancellationDate,
|
||||||
|
OPPORTUNITY_FIELD_MAP.cancellationNotice,
|
||||||
|
OPPORTUNITY_FIELD_MAP.lineReturnStatus,
|
||||||
|
OPPORTUNITY_FIELD_MAP.lastModifiedDate,
|
||||||
|
// NOTE: Cancellation comments and alternative email are on the Cancellation Case
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields to select for cancellation status display
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_CANCELLATION_QUERY_FIELDS = [
|
||||||
|
OPPORTUNITY_FIELD_MAP.id,
|
||||||
|
OPPORTUNITY_FIELD_MAP.stage,
|
||||||
|
OPPORTUNITY_FIELD_MAP.commodityType,
|
||||||
|
OPPORTUNITY_FIELD_MAP.scheduledCancellationDate,
|
||||||
|
OPPORTUNITY_FIELD_MAP.cancellationNotice,
|
||||||
|
OPPORTUNITY_FIELD_MAP.lineReturnStatus,
|
||||||
|
OPPORTUNITY_FIELD_MAP.whmcsServiceId,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stage Picklist Reference (Existing Values)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opportunity stage picklist values (already exist in Salesforce)
|
||||||
|
*
|
||||||
|
* These stages track the complete service lifecycle.
|
||||||
|
* The portal uses these exact values.
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_STAGE_REFERENCE = {
|
||||||
|
INTRODUCTION: {
|
||||||
|
value: "Introduction",
|
||||||
|
probability: 30,
|
||||||
|
forecastCategory: "Pipeline",
|
||||||
|
isClosed: false,
|
||||||
|
description: "Initial customer interest / eligibility pending",
|
||||||
|
},
|
||||||
|
WIKI: {
|
||||||
|
value: "WIKI",
|
||||||
|
probability: 10,
|
||||||
|
forecastCategory: "Omitted",
|
||||||
|
isClosed: false,
|
||||||
|
description: "Low priority / informational only",
|
||||||
|
},
|
||||||
|
READY: {
|
||||||
|
value: "Ready",
|
||||||
|
probability: 60,
|
||||||
|
forecastCategory: "Pipeline",
|
||||||
|
isClosed: false,
|
||||||
|
description: "Eligible and ready to order",
|
||||||
|
},
|
||||||
|
POST_PROCESSING: {
|
||||||
|
value: "Post Processing",
|
||||||
|
probability: 75,
|
||||||
|
forecastCategory: "Pipeline",
|
||||||
|
isClosed: false,
|
||||||
|
description: "Order placed, processing",
|
||||||
|
},
|
||||||
|
ACTIVE: {
|
||||||
|
value: "Active",
|
||||||
|
probability: 90,
|
||||||
|
forecastCategory: "Pipeline",
|
||||||
|
isClosed: false,
|
||||||
|
description: "Service is active",
|
||||||
|
},
|
||||||
|
CANCELLING: {
|
||||||
|
value: "△Cancelling",
|
||||||
|
probability: 100,
|
||||||
|
forecastCategory: "Pipeline",
|
||||||
|
isClosed: false,
|
||||||
|
description: "Cancellation requested, pending processing",
|
||||||
|
},
|
||||||
|
CANCELLED: {
|
||||||
|
value: "〇Cancelled",
|
||||||
|
probability: 100,
|
||||||
|
forecastCategory: "Closed",
|
||||||
|
isClosed: true,
|
||||||
|
isWon: true,
|
||||||
|
description: "Successfully cancelled",
|
||||||
|
},
|
||||||
|
COMPLETED: {
|
||||||
|
value: "Completed",
|
||||||
|
probability: 100,
|
||||||
|
forecastCategory: "Closed",
|
||||||
|
isClosed: true,
|
||||||
|
isWon: true,
|
||||||
|
description: "Service completed normally",
|
||||||
|
},
|
||||||
|
VOID: {
|
||||||
|
value: "Void",
|
||||||
|
probability: 0,
|
||||||
|
forecastCategory: "Omitted",
|
||||||
|
isClosed: true,
|
||||||
|
isWon: false,
|
||||||
|
description: "Lost / not eligible",
|
||||||
|
},
|
||||||
|
PENDING: {
|
||||||
|
value: "Pending",
|
||||||
|
probability: 0,
|
||||||
|
forecastCategory: "Omitted",
|
||||||
|
isClosed: true,
|
||||||
|
isWon: false,
|
||||||
|
description: "On hold / abandoned",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Application Stage Reference (Existing Values)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application stage picklist values (already exist in Salesforce)
|
||||||
|
* Portal uses INTRO-1 for new opportunities
|
||||||
|
*/
|
||||||
|
export const APPLICATION_STAGE_REFERENCE = {
|
||||||
|
INTRO_1: { value: "INTRO-1", description: "Portal introduction (default)" },
|
||||||
|
NA: { value: "N/A", description: "Not applicable" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cancellation Notice Picklist Reference (Existing Values)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation notice picklist values (already exist in Salesforce)
|
||||||
|
*/
|
||||||
|
export const CANCELLATION_NOTICE_REFERENCE = {
|
||||||
|
RECEIVED: { value: "有", label: "Received", description: "Cancellation form received" },
|
||||||
|
NOT_YET: { value: "未", label: "Not Yet", description: "Not yet received (default)" },
|
||||||
|
NOT_REQUIRED: { value: "不要", label: "Not Required", description: "Not required" },
|
||||||
|
TRANSFER: { value: "移転", label: "Transfer", description: "Customer moving/transferring" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Line Return Status Picklist Reference (Existing Values)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line return status picklist values (already exist in Salesforce)
|
||||||
|
*/
|
||||||
|
export const LINE_RETURN_STATUS_REFERENCE = {
|
||||||
|
NOT_YET: { value: "NotYet", label: "Not Yet", description: "Return kit not sent" },
|
||||||
|
SENT_KIT: { value: "SentKit", label: "Kit Sent", description: "Return kit sent to customer" },
|
||||||
|
PICKUP_SCHEDULED: {
|
||||||
|
value: "AS/Pickup予定",
|
||||||
|
label: "Pickup Scheduled",
|
||||||
|
description: "Pickup scheduled",
|
||||||
|
},
|
||||||
|
RETURNED_1: { value: "Returned1", label: "Returned", description: "Equipment returned" },
|
||||||
|
RETURNED: { value: "Returned2", label: "Returned", description: "Equipment returned" },
|
||||||
|
NTT_DISPATCH: { value: "NTT派遣", label: "NTT Dispatch", description: "NTT handling return" },
|
||||||
|
COMPENSATED: {
|
||||||
|
value: "Compensated",
|
||||||
|
label: "Compensated",
|
||||||
|
description: "Compensation fee charged",
|
||||||
|
},
|
||||||
|
NA: { value: "N/A", label: "N/A", description: "No rental equipment" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Commodity Type Reference (Existing Values)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommodityType picklist values (already exist in Salesforce)
|
||||||
|
* Maps to simplified product types for portal logic
|
||||||
|
*/
|
||||||
|
export const COMMODITY_TYPE_REFERENCE = {
|
||||||
|
PERSONAL_HOME_INTERNET: {
|
||||||
|
value: "Personal SonixNet Home Internet",
|
||||||
|
portalProductType: "Internet",
|
||||||
|
description: "Personal home internet service",
|
||||||
|
},
|
||||||
|
CORPORATE_HOME_INTERNET: {
|
||||||
|
value: "Corporate SonixNet Home Internet",
|
||||||
|
portalProductType: "Internet",
|
||||||
|
description: "Corporate home internet service",
|
||||||
|
},
|
||||||
|
SIM: {
|
||||||
|
value: "SIM",
|
||||||
|
portalProductType: "SIM",
|
||||||
|
description: "SIM / mobile service",
|
||||||
|
},
|
||||||
|
VPN: {
|
||||||
|
value: "VPN",
|
||||||
|
portalProductType: "VPN",
|
||||||
|
description: "VPN service",
|
||||||
|
},
|
||||||
|
TECH_SUPPORT: {
|
||||||
|
value: "Onsite Support",
|
||||||
|
portalProductType: null,
|
||||||
|
description: "Tech support (not used by portal)",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// New Picklist Values (to be created in Salesforce)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source picklist values for Portal_Source__c field
|
||||||
|
* (needs to be created in Salesforce)
|
||||||
|
*/
|
||||||
|
export const PORTAL_SOURCE_PICKLIST = [
|
||||||
|
{ value: "Portal - Internet Eligibility Request", label: "Portal - Internet Eligibility" },
|
||||||
|
{ value: "Portal - SIM Checkout Registration", label: "Portal - SIM Checkout" },
|
||||||
|
{ value: "Portal - Order Placement", label: "Portal - Order Placement" },
|
||||||
|
{ value: "Agent Created", label: "Agent Created" },
|
||||||
|
] as const;
|
||||||
@ -6,6 +6,7 @@ import { SalesforceConnection } from "./services/salesforce-connection.service.j
|
|||||||
import { SalesforceAccountService } from "./services/salesforce-account.service.js";
|
import { SalesforceAccountService } from "./services/salesforce-account.service.js";
|
||||||
import { SalesforceOrderService } from "./services/salesforce-order.service.js";
|
import { SalesforceOrderService } from "./services/salesforce-order.service.js";
|
||||||
import { SalesforceCaseService } from "./services/salesforce-case.service.js";
|
import { SalesforceCaseService } from "./services/salesforce-case.service.js";
|
||||||
|
import { SalesforceOpportunityService } from "./services/salesforce-opportunity.service.js";
|
||||||
import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module.js";
|
import { OrderFieldConfigModule } from "@bff/modules/orders/config/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";
|
||||||
@ -17,6 +18,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
|||||||
SalesforceAccountService,
|
SalesforceAccountService,
|
||||||
SalesforceOrderService,
|
SalesforceOrderService,
|
||||||
SalesforceCaseService,
|
SalesforceCaseService,
|
||||||
|
SalesforceOpportunityService,
|
||||||
SalesforceService,
|
SalesforceService,
|
||||||
SalesforceReadThrottleGuard,
|
SalesforceReadThrottleGuard,
|
||||||
SalesforceWriteThrottleGuard,
|
SalesforceWriteThrottleGuard,
|
||||||
@ -28,6 +30,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
|||||||
SalesforceAccountService,
|
SalesforceAccountService,
|
||||||
SalesforceOrderService,
|
SalesforceOrderService,
|
||||||
SalesforceCaseService,
|
SalesforceCaseService,
|
||||||
|
SalesforceOpportunityService,
|
||||||
SalesforceReadThrottleGuard,
|
SalesforceReadThrottleGuard,
|
||||||
SalesforceWriteThrottleGuard,
|
SalesforceWriteThrottleGuard,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -258,4 +258,148 @@ export class SalesforceCaseService {
|
|||||||
|
|
||||||
return result.records?.[0] ?? null;
|
return result.records?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Opportunity-Linked Cases
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an eligibility check case linked to an Opportunity
|
||||||
|
*
|
||||||
|
* @param params - Case parameters including Opportunity link
|
||||||
|
* @returns Created case ID
|
||||||
|
*/
|
||||||
|
async createEligibilityCase(params: {
|
||||||
|
accountId: string;
|
||||||
|
opportunityId: string;
|
||||||
|
subject: string;
|
||||||
|
description: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
||||||
|
const safeOpportunityId = assertSalesforceId(params.opportunityId, "opportunityId");
|
||||||
|
|
||||||
|
this.logger.log("Creating eligibility check case linked to Opportunity", {
|
||||||
|
accountIdTail: safeAccountId.slice(-4),
|
||||||
|
opportunityIdTail: safeOpportunityId.slice(-4),
|
||||||
|
});
|
||||||
|
|
||||||
|
const casePayload: Record<string, unknown> = {
|
||||||
|
Origin: "Portal",
|
||||||
|
Status: SALESFORCE_CASE_STATUS.NEW,
|
||||||
|
Priority: SALESFORCE_CASE_PRIORITY.MEDIUM,
|
||||||
|
Subject: params.subject,
|
||||||
|
Description: params.description,
|
||||||
|
AccountId: safeAccountId,
|
||||||
|
// Link Case to Opportunity - this is a standard lookup field
|
||||||
|
OpportunityId: safeOpportunityId,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
||||||
|
|
||||||
|
if (!created.id) {
|
||||||
|
throw new Error("Salesforce did not return a case ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log("Eligibility case created and linked to Opportunity", {
|
||||||
|
caseId: created.id,
|
||||||
|
opportunityIdTail: safeOpportunityId.slice(-4),
|
||||||
|
});
|
||||||
|
|
||||||
|
return created.id;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.error("Failed to create eligibility case", {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
accountIdTail: safeAccountId.slice(-4),
|
||||||
|
});
|
||||||
|
throw new Error("Failed to create eligibility check case");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a cancellation request case linked to an Opportunity
|
||||||
|
*
|
||||||
|
* All customer-provided details (comments, alternative email) go here.
|
||||||
|
* The Opportunity only gets the core lifecycle fields (dates, status).
|
||||||
|
*
|
||||||
|
* @param params - Cancellation case parameters
|
||||||
|
* @returns Created case ID
|
||||||
|
*/
|
||||||
|
async createCancellationCase(params: {
|
||||||
|
accountId: string;
|
||||||
|
opportunityId?: string;
|
||||||
|
whmcsServiceId: number;
|
||||||
|
productType: string;
|
||||||
|
cancellationMonth: string;
|
||||||
|
cancellationDate: string;
|
||||||
|
alternativeEmail?: string;
|
||||||
|
comments?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
|
||||||
|
const safeOpportunityId = params.opportunityId
|
||||||
|
? assertSalesforceId(params.opportunityId, "opportunityId")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.logger.log("Creating cancellation request case", {
|
||||||
|
accountIdTail: safeAccountId.slice(-4),
|
||||||
|
opportunityId: safeOpportunityId ? safeOpportunityId.slice(-4) : "none",
|
||||||
|
whmcsServiceId: params.whmcsServiceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build description with all form data
|
||||||
|
const descriptionLines = [
|
||||||
|
`Cancellation Request from Portal`,
|
||||||
|
``,
|
||||||
|
`Product Type: ${params.productType}`,
|
||||||
|
`WHMCS Service ID: ${params.whmcsServiceId}`,
|
||||||
|
`Cancellation Month: ${params.cancellationMonth}`,
|
||||||
|
`Service End Date: ${params.cancellationDate}`,
|
||||||
|
``,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (params.alternativeEmail) {
|
||||||
|
descriptionLines.push(`Alternative Contact Email: ${params.alternativeEmail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.comments) {
|
||||||
|
descriptionLines.push(``, `Customer Comments:`, params.comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptionLines.push(``, `Submitted: ${new Date().toISOString()}`);
|
||||||
|
|
||||||
|
const casePayload: Record<string, unknown> = {
|
||||||
|
Origin: "Portal",
|
||||||
|
Status: SALESFORCE_CASE_STATUS.NEW,
|
||||||
|
Priority: SALESFORCE_CASE_PRIORITY.HIGH,
|
||||||
|
Subject: `Cancellation Request - ${params.productType} (${params.cancellationMonth})`,
|
||||||
|
Description: descriptionLines.join("\n"),
|
||||||
|
AccountId: safeAccountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Link to Opportunity if we have one
|
||||||
|
if (safeOpportunityId) {
|
||||||
|
casePayload.OpportunityId = safeOpportunityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
|
||||||
|
|
||||||
|
if (!created.id) {
|
||||||
|
throw new Error("Salesforce did not return a case ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log("Cancellation case created", {
|
||||||
|
caseId: created.id,
|
||||||
|
hasOpportunityLink: !!safeOpportunityId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return created.id;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.error("Failed to create cancellation case", {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
accountIdTail: safeAccountId.slice(-4),
|
||||||
|
});
|
||||||
|
throw new Error("Failed to create cancellation request case");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,736 @@
|
|||||||
|
/**
|
||||||
|
* Salesforce Opportunity Integration Service
|
||||||
|
*
|
||||||
|
* Manages Opportunity records for service lifecycle tracking.
|
||||||
|
* Opportunities track customer journeys from interest through cancellation.
|
||||||
|
*
|
||||||
|
* Key responsibilities:
|
||||||
|
* - Create Opportunities at interest triggers (eligibility request, registration)
|
||||||
|
* - Update Opportunity stages as orders progress
|
||||||
|
* - Link WHMCS services to Opportunities for cancellation workflows
|
||||||
|
* - Store cancellation form data on Opportunities
|
||||||
|
*
|
||||||
|
* Uses existing Salesforce stage values:
|
||||||
|
* - Introduction → Ready → Post Processing → Active → △Cancelling → 〇Cancelled
|
||||||
|
*
|
||||||
|
* @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import type { SalesforceResponse } from "@customer-portal/domain/common";
|
||||||
|
import {
|
||||||
|
type OpportunityStageValue,
|
||||||
|
type OpportunityProductTypeValue,
|
||||||
|
type OpportunitySourceValue,
|
||||||
|
type ApplicationStageValue,
|
||||||
|
type CancellationNoticeValue,
|
||||||
|
type LineReturnStatusValue,
|
||||||
|
type CommodityTypeValue,
|
||||||
|
type CancellationOpportunityData,
|
||||||
|
type CreateOpportunityRequest,
|
||||||
|
type OpportunityRecord,
|
||||||
|
OPPORTUNITY_STAGE,
|
||||||
|
APPLICATION_STAGE,
|
||||||
|
OPEN_OPPORTUNITY_STAGES,
|
||||||
|
COMMODITY_TYPE,
|
||||||
|
OPPORTUNITY_PRODUCT_TYPE,
|
||||||
|
getDefaultCommodityType,
|
||||||
|
getCommodityTypeProductType,
|
||||||
|
} from "@customer-portal/domain/opportunity";
|
||||||
|
import {
|
||||||
|
OPPORTUNITY_FIELD_MAP,
|
||||||
|
OPPORTUNITY_MATCH_QUERY_FIELDS,
|
||||||
|
OPPORTUNITY_DETAIL_QUERY_FIELDS,
|
||||||
|
OPPORTUNITY_CANCELLATION_QUERY_FIELDS,
|
||||||
|
} from "../config/opportunity-field-map.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw Opportunity record from Salesforce query
|
||||||
|
*/
|
||||||
|
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; // Existing product type field
|
||||||
|
ScheduledCancellationDateAndTime__c?: string;
|
||||||
|
CancellationNotice__c?: string;
|
||||||
|
LineReturn__c?: string;
|
||||||
|
// New custom fields (to be created)
|
||||||
|
Portal_Source__c?: string;
|
||||||
|
WHMCS_Service_ID__c?: number;
|
||||||
|
// Note: Cases and Orders link TO Opportunity via their OpportunityId field
|
||||||
|
// Cancellation comments and alternative email are on the Cancellation Case
|
||||||
|
// Relationship fields
|
||||||
|
Account?: { Name?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SalesforceOpportunityService {
|
||||||
|
constructor(
|
||||||
|
private readonly sf: SalesforceConnection,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Core CRUD Operations
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Opportunity in Salesforce
|
||||||
|
*
|
||||||
|
* @param request - Opportunity creation parameters
|
||||||
|
* @returns The created Opportunity ID
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Create for Internet eligibility request
|
||||||
|
* const oppId = await service.createOpportunity({
|
||||||
|
* accountId: 'SF_ACCOUNT_ID',
|
||||||
|
* productType: 'Internet',
|
||||||
|
* stage: 'Introduction',
|
||||||
|
* source: 'Portal - Internet Eligibility Request',
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Then create a Case linked to this Opportunity:
|
||||||
|
* await caseService.createCase({
|
||||||
|
* type: 'Eligibility Check',
|
||||||
|
* opportunityId: oppId, // Case links TO Opportunity
|
||||||
|
* ...
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opportunity Name - Salesforce workflow will auto-generate the real name
|
||||||
|
// We provide a placeholder that includes product type for debugging
|
||||||
|
const opportunityName = `Portal - ${request.productType}`;
|
||||||
|
|
||||||
|
// Calculate close date (default: 30 days from now)
|
||||||
|
const closeDate =
|
||||||
|
request.closeDate ?? this.calculateCloseDate(request.productType, request.stage);
|
||||||
|
|
||||||
|
// Application stage defaults to INTRO-1 for portal
|
||||||
|
const applicationStage = request.applicationStage ?? APPLICATION_STAGE.INTRO_1;
|
||||||
|
|
||||||
|
// Get the CommodityType from the simplified product type
|
||||||
|
const commodityType = getDefaultCommodityType(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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add optional custom fields (only if they exist in Salesforce)
|
||||||
|
if (request.source) {
|
||||||
|
payload[OPPORTUNITY_FIELD_MAP.source] = request.source;
|
||||||
|
}
|
||||||
|
// Note: Cases (eligibility, ID verification) link TO Opportunity via Case.OpportunityId
|
||||||
|
// Orders link TO Opportunity via Order.OpportunityId
|
||||||
|
|
||||||
|
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) {
|
||||||
|
this.logger.error("Failed to create Opportunity", {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
accountId: safeAccountId,
|
||||||
|
productType: request.productType,
|
||||||
|
});
|
||||||
|
throw new Error("Failed to create service lifecycle record");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Opportunity stage
|
||||||
|
*
|
||||||
|
* @param opportunityId - Salesforce Opportunity ID
|
||||||
|
* @param stage - New stage value (must be valid Salesforce picklist value)
|
||||||
|
* @param reason - Optional reason for stage change (for audit)
|
||||||
|
*/
|
||||||
|
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: getErrorMessage(error),
|
||||||
|
opportunityId: safeOppId,
|
||||||
|
stage,
|
||||||
|
});
|
||||||
|
throw new Error("Failed to update service lifecycle stage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Opportunity with cancellation data from form submission
|
||||||
|
*
|
||||||
|
* Sets:
|
||||||
|
* - Stage to △Cancelling
|
||||||
|
* - ScheduledCancellationDateAndTime__c
|
||||||
|
* - CancellationNotice__c to 有 (received)
|
||||||
|
* - LineReturn__c to NotYet
|
||||||
|
*
|
||||||
|
* NOTE: Comments and alternative email go on the Cancellation Case, not Opportunity.
|
||||||
|
* The Case is created separately and linked to this Opportunity via Case.OpportunityId.
|
||||||
|
*
|
||||||
|
* @param opportunityId - Salesforce Opportunity ID
|
||||||
|
* @param data - Cancellation data (dates and status flags)
|
||||||
|
*/
|
||||||
|
async updateCancellationData(
|
||||||
|
opportunityId: string,
|
||||||
|
data: CancellationOpportunityData
|
||||||
|
): Promise<void> {
|
||||||
|
const safeOppId = assertSalesforceId(opportunityId, "opportunityId");
|
||||||
|
|
||||||
|
this.logger.log("Updating Opportunity with cancellation data", {
|
||||||
|
opportunityId: safeOppId,
|
||||||
|
scheduledDate: data.scheduledCancellationDate,
|
||||||
|
cancellationNotice: data.cancellationNotice,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
Id: safeOppId,
|
||||||
|
[OPPORTUNITY_FIELD_MAP.stage]: OPPORTUNITY_STAGE.CANCELLING,
|
||||||
|
[OPPORTUNITY_FIELD_MAP.scheduledCancellationDate]: data.scheduledCancellationDate,
|
||||||
|
[OPPORTUNITY_FIELD_MAP.cancellationNotice]: data.cancellationNotice,
|
||||||
|
[OPPORTUNITY_FIELD_MAP.lineReturnStatus]: data.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 cancellation data updated successfully", {
|
||||||
|
opportunityId: safeOppId,
|
||||||
|
scheduledDate: data.scheduledCancellationDate,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to update Opportunity cancellation data", {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
opportunityId: safeOppId,
|
||||||
|
});
|
||||||
|
throw new Error("Failed to update cancellation information");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Lookup Operations
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an open Opportunity for an account by product type
|
||||||
|
*
|
||||||
|
* Used for matching orders to existing Opportunities
|
||||||
|
*
|
||||||
|
* @param accountId - Salesforce Account ID
|
||||||
|
* @param productType - Product type to match
|
||||||
|
* @returns Opportunity ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
async findOpenOpportunityForAccount(
|
||||||
|
accountId: string,
|
||||||
|
productType: OpportunityProductTypeValue
|
||||||
|
): Promise<string | null> {
|
||||||
|
const safeAccountId = assertSalesforceId(accountId, "accountId");
|
||||||
|
|
||||||
|
// Get the CommodityType value(s) that match this product type
|
||||||
|
const commodityTypeValues = this.getCommodityTypesForProductType(productType);
|
||||||
|
|
||||||
|
this.logger.debug("Looking for open Opportunity", {
|
||||||
|
accountId: safeAccountId,
|
||||||
|
productType,
|
||||||
|
commodityTypes: commodityTypeValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build stage filter for open stages
|
||||||
|
const stageList = OPEN_OPPORTUNITY_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) {
|
||||||
|
this.logger.error("Failed to find open Opportunity", {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
accountId: safeAccountId,
|
||||||
|
productType,
|
||||||
|
});
|
||||||
|
// Don't throw - return null to allow fallback to creation
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find Opportunity linked to an Order
|
||||||
|
*
|
||||||
|
* @param orderId - Salesforce Order ID
|
||||||
|
* @returns Opportunity ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
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: getErrorMessage(error),
|
||||||
|
orderId: safeOrderId,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find Opportunity by WHMCS Service ID
|
||||||
|
*
|
||||||
|
* Used for cancellation workflows to find the Opportunity to update
|
||||||
|
*
|
||||||
|
* @param whmcsServiceId - WHMCS Service/Hosting ID
|
||||||
|
* @returns Opportunity ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
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: getErrorMessage(error),
|
||||||
|
whmcsServiceId,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full Opportunity details by ID
|
||||||
|
*
|
||||||
|
* @param opportunityId - Salesforce Opportunity ID
|
||||||
|
* @returns Opportunity record or null if not found
|
||||||
|
*/
|
||||||
|
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: getErrorMessage(error),
|
||||||
|
opportunityId: safeOppId,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cancellation status for display in portal
|
||||||
|
*
|
||||||
|
* @param whmcsServiceId - WHMCS Service ID
|
||||||
|
* @returns Cancellation status details or null
|
||||||
|
*/
|
||||||
|
async getCancellationStatus(whmcsServiceId: number): Promise<{
|
||||||
|
stage: OpportunityStageValue;
|
||||||
|
isPending: boolean;
|
||||||
|
isComplete: boolean;
|
||||||
|
scheduledEndDate?: string;
|
||||||
|
rentalReturnStatus?: LineReturnStatusValue;
|
||||||
|
} | null> {
|
||||||
|
const soql = `
|
||||||
|
SELECT ${OPPORTUNITY_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:getCancellationStatus",
|
||||||
|
})) as SalesforceResponse<SalesforceOpportunityRecord>;
|
||||||
|
|
||||||
|
const record = result.records?.[0];
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
const stage = record.StageName as OpportunityStageValue;
|
||||||
|
const isPending = stage === OPPORTUNITY_STAGE.CANCELLING;
|
||||||
|
const isComplete = stage === OPPORTUNITY_STAGE.CANCELLED;
|
||||||
|
|
||||||
|
return {
|
||||||
|
stage,
|
||||||
|
isPending,
|
||||||
|
isComplete,
|
||||||
|
scheduledEndDate: record.ScheduledCancellationDateAndTime__c,
|
||||||
|
rentalReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to get cancellation status", {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
whmcsServiceId,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Lifecycle Helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a WHMCS Service ID to an Opportunity
|
||||||
|
*
|
||||||
|
* Called after provisioning to enable cancellation workflows
|
||||||
|
*
|
||||||
|
* @param opportunityId - Salesforce Opportunity ID
|
||||||
|
* @param whmcsServiceId - WHMCS Service/Hosting ID
|
||||||
|
*/
|
||||||
|
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: getErrorMessage(error),
|
||||||
|
opportunityId: safeOppId,
|
||||||
|
whmcsServiceId,
|
||||||
|
});
|
||||||
|
// Don't throw - this is a non-critical update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link an Order to an Opportunity (update Order.OpportunityId)
|
||||||
|
*
|
||||||
|
* Note: This updates the Order record, not the Opportunity
|
||||||
|
*
|
||||||
|
* @param orderId - Salesforce Order ID
|
||||||
|
* @param opportunityId - Salesforce Opportunity ID
|
||||||
|
*/
|
||||||
|
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: getErrorMessage(error),
|
||||||
|
orderId: safeOrderId,
|
||||||
|
opportunityId: safeOppId,
|
||||||
|
});
|
||||||
|
// Don't throw - this is a non-critical update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark cancellation as complete
|
||||||
|
*
|
||||||
|
* @param opportunityId - Opportunity ID
|
||||||
|
*/
|
||||||
|
async markCancellationComplete(opportunityId: string): Promise<void> {
|
||||||
|
await this.updateStage(opportunityId, OPPORTUNITY_STAGE.CANCELLED, "Cancellation completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Private Helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate close date based on product type and stage
|
||||||
|
*/
|
||||||
|
private calculateCloseDate(
|
||||||
|
productType: OpportunityProductTypeValue,
|
||||||
|
stage: OpportunityStageValue
|
||||||
|
): string {
|
||||||
|
const today = new Date();
|
||||||
|
let daysToAdd: number;
|
||||||
|
|
||||||
|
// Different close date expectations based on stage/product
|
||||||
|
switch (stage) {
|
||||||
|
case OPPORTUNITY_STAGE.INTRODUCTION:
|
||||||
|
// Internet eligibility - may take 30 days
|
||||||
|
daysToAdd = 30;
|
||||||
|
break;
|
||||||
|
case OPPORTUNITY_STAGE.READY:
|
||||||
|
// Ready to order - expected soon
|
||||||
|
daysToAdd = 14;
|
||||||
|
break;
|
||||||
|
case OPPORTUNITY_STAGE.POST_PROCESSING:
|
||||||
|
// Order placed - expected within 7 days
|
||||||
|
daysToAdd = 7;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Default: 30 days
|
||||||
|
daysToAdd = 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDate = new Date(today);
|
||||||
|
closeDate.setDate(closeDate.getDate() + daysToAdd);
|
||||||
|
|
||||||
|
return closeDate.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CommodityType values that match a simplified product type
|
||||||
|
* Used for querying opportunities by product category
|
||||||
|
*/
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform Salesforce record to domain OpportunityRecord
|
||||||
|
*/
|
||||||
|
private transformToOpportunityRecord(record: SalesforceOpportunityRecord): OpportunityRecord {
|
||||||
|
// Derive productType from CommodityType (existing Salesforce field)
|
||||||
|
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.Portal_Source__c as OpportunitySourceValue | undefined,
|
||||||
|
applicationStage: record.Application_Stage__c as ApplicationStageValue | undefined,
|
||||||
|
isClosed: record.IsClosed,
|
||||||
|
// Note: Related Cases and Orders are queried separately via their OpportunityId field
|
||||||
|
whmcsServiceId: record.WHMCS_Service_ID__c,
|
||||||
|
// Cancellation fields (updated by CS when processing cancellation Case)
|
||||||
|
scheduledCancellationDate: record.ScheduledCancellationDateAndTime__c,
|
||||||
|
cancellationNotice: record.CancellationNotice__c as CancellationNoticeValue | undefined,
|
||||||
|
lineReturnStatus: record.LineReturn__c as LineReturnStatusValue | undefined,
|
||||||
|
// NOTE: alternativeContactEmail and cancellationComments are on Cancellation Case
|
||||||
|
createdDate: record.CreatedDate,
|
||||||
|
lastModifiedDate: record.LastModifiedDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
HttpStatus,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@ -19,11 +20,13 @@ import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
|||||||
import { AuthTokenService } from "../../token/token.service.js";
|
import { AuthTokenService } from "../../token/token.service.js";
|
||||||
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
|
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||||
import {
|
import {
|
||||||
signupRequestSchema,
|
signupRequestSchema,
|
||||||
type SignupRequest,
|
type SignupRequest,
|
||||||
type ValidateSignupRequest,
|
type ValidateSignupRequest,
|
||||||
} from "@customer-portal/domain/auth";
|
} from "@customer-portal/domain/auth";
|
||||||
|
import { ErrorCode } from "@customer-portal/domain/common";
|
||||||
import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
|
import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||||
import type { User as PrismaUser } from "@prisma/client";
|
import type { User as PrismaUser } from "@prisma/client";
|
||||||
@ -302,9 +305,7 @@ export class SignupWorkflowService {
|
|||||||
throw new ConflictException("You already have an account. Please sign in.");
|
throw new ConflictException("You already have an account. Please sign in.");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ConflictException(
|
throw new DomainHttpException(ErrorCode.LEGACY_ACCOUNT_EXISTS, HttpStatus.CONFLICT);
|
||||||
"We found an existing billing account for this email. Please link your account instead."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (pre) {
|
} catch (pre) {
|
||||||
if (!(pre instanceof NotFoundException)) {
|
if (!(pre instanceof NotFoundException)) {
|
||||||
@ -556,7 +557,7 @@ export class SignupWorkflowService {
|
|||||||
|
|
||||||
result.nextAction = "link_whmcs";
|
result.nextAction = "link_whmcs";
|
||||||
result.messages.push(
|
result.messages.push(
|
||||||
"We found an existing billing account for this email. Please transfer your account."
|
"We found an existing billing account for this email. Please transfer your account to continue."
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@ -623,7 +624,7 @@ export class SignupWorkflowService {
|
|||||||
|
|
||||||
result.nextAction = "link_whmcs";
|
result.nextAction = "link_whmcs";
|
||||||
result.messages.push(
|
result.messages.push(
|
||||||
"We found an existing billing account for this email. Please link your account."
|
"We found an existing billing account for this email. Please transfer your account to continue."
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,12 +16,19 @@ import {
|
|||||||
} from "@customer-portal/domain/catalog";
|
} from "@customer-portal/domain/catalog";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||||
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
|
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||||
import { assertSoqlFieldName } from "@bff/integrations/salesforce/utils/soql.util.js";
|
import { assertSoqlFieldName } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||||
import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js";
|
import type { InternetEligibilityCheckRequest } from "./internet-eligibility.types.js";
|
||||||
import type { SalesforceResponse } from "@customer-portal/domain/common";
|
import type { SalesforceResponse } from "@customer-portal/domain/common";
|
||||||
|
import {
|
||||||
|
OPPORTUNITY_STAGE,
|
||||||
|
OPPORTUNITY_SOURCE,
|
||||||
|
OPPORTUNITY_PRODUCT_TYPE,
|
||||||
|
} from "@customer-portal/domain/opportunity";
|
||||||
|
|
||||||
export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
|
export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
|
||||||
|
|
||||||
@ -41,7 +48,9 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
@Inject(Logger) logger: Logger,
|
@Inject(Logger) logger: Logger,
|
||||||
private mappingsService: MappingsService,
|
private mappingsService: MappingsService,
|
||||||
private catalogCache: CatalogCacheService
|
private catalogCache: CatalogCacheService,
|
||||||
|
private opportunityService: SalesforceOpportunityService,
|
||||||
|
private caseService: SalesforceCaseService
|
||||||
) {
|
) {
|
||||||
super(sf, config, logger);
|
super(sf, config, logger);
|
||||||
}
|
}
|
||||||
@ -268,39 +277,71 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
throw new BadRequestException("Service address is required to request eligibility review.");
|
throw new BadRequestException("Service address is required to request eligibility review.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const subject = "Internet availability check request (Portal)";
|
|
||||||
const descriptionLines: string[] = [
|
|
||||||
"Portal internet availability check requested.",
|
|
||||||
"",
|
|
||||||
`UserId: ${userId}`,
|
|
||||||
`Email: ${request.email}`,
|
|
||||||
`SalesforceAccountId: ${sfAccountId}`,
|
|
||||||
"",
|
|
||||||
request.notes ? `Notes: ${request.notes}` : "",
|
|
||||||
request.address ? `Address: ${formatAddressForLog(request.address)}` : "",
|
|
||||||
"",
|
|
||||||
`RequestedAt: ${new Date().toISOString()}`,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requestId = await this.createEligibilityCaseOrTask(sfAccountId, {
|
// 1. Find or create Opportunity for Internet eligibility
|
||||||
|
// Only match Introduction stage (not Ready/Post Processing - those have progressed)
|
||||||
|
let opportunityId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||||
|
sfAccountId,
|
||||||
|
OPPORTUNITY_PRODUCT_TYPE.INTERNET
|
||||||
|
);
|
||||||
|
|
||||||
|
let opportunityCreated = false;
|
||||||
|
if (!opportunityId) {
|
||||||
|
// Create Opportunity - Salesforce workflow auto-generates the name
|
||||||
|
opportunityId = await this.opportunityService.createOpportunity({
|
||||||
|
accountId: sfAccountId,
|
||||||
|
productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET,
|
||||||
|
stage: OPPORTUNITY_STAGE.INTRODUCTION,
|
||||||
|
source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY,
|
||||||
|
});
|
||||||
|
opportunityCreated = true;
|
||||||
|
|
||||||
|
this.logger.log("Created Opportunity for eligibility request", {
|
||||||
|
opportunityIdTail: opportunityId.slice(-4),
|
||||||
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build case description
|
||||||
|
const subject = "Internet availability check request (Portal)";
|
||||||
|
const descriptionLines: string[] = [
|
||||||
|
"Portal internet availability check requested.",
|
||||||
|
"",
|
||||||
|
`UserId: ${userId}`,
|
||||||
|
`Email: ${request.email}`,
|
||||||
|
`SalesforceAccountId: ${sfAccountId}`,
|
||||||
|
`OpportunityId: ${opportunityId}`,
|
||||||
|
"",
|
||||||
|
request.notes ? `Notes: ${request.notes}` : "",
|
||||||
|
request.address ? `Address: ${formatAddressForLog(request.address)}` : "",
|
||||||
|
"",
|
||||||
|
`RequestedAt: ${new Date().toISOString()}`,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
// 3. Create Case linked to Opportunity
|
||||||
|
const caseId = await this.caseService.createEligibilityCase({
|
||||||
|
accountId: sfAccountId,
|
||||||
|
opportunityId,
|
||||||
subject,
|
subject,
|
||||||
description: descriptionLines.join("\n"),
|
description: descriptionLines.join("\n"),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.updateAccountEligibilityRequestState(sfAccountId, requestId);
|
// 4. Update Account eligibility status
|
||||||
|
await this.updateAccountEligibilityRequestState(sfAccountId, caseId);
|
||||||
|
|
||||||
await this.catalogCache.invalidateEligibility(sfAccountId);
|
await this.catalogCache.invalidateEligibility(sfAccountId);
|
||||||
|
|
||||||
this.logger.log("Created Salesforce Task for internet eligibility request", {
|
this.logger.log("Created eligibility Case linked to Opportunity", {
|
||||||
userId,
|
userId,
|
||||||
sfAccountIdTail: sfAccountId.slice(-4),
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
taskIdTail: requestId.slice(-4),
|
caseIdTail: caseId.slice(-4),
|
||||||
|
opportunityIdTail: opportunityId.slice(-4),
|
||||||
|
opportunityCreated,
|
||||||
});
|
});
|
||||||
|
|
||||||
return requestId;
|
return caseId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to create Salesforce Task for internet eligibility request", {
|
this.logger.error("Failed to create eligibility request", {
|
||||||
userId,
|
userId,
|
||||||
sfAccountId,
|
sfAccountId,
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
@ -413,49 +454,8 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
return { status, eligibility, requestId, requestedAt, checkedAt, notes };
|
return { status, eligibility, requestId, requestedAt, checkedAt, notes };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createEligibilityCaseOrTask(
|
// Note: createEligibilityCaseOrTask was removed - now using this.caseService.createEligibilityCase()
|
||||||
sfAccountId: string,
|
// which links the Case to the Opportunity
|
||||||
payload: { subject: string; description: string }
|
|
||||||
): Promise<string> {
|
|
||||||
const caseCreate = this.sf.sobject("Case")?.create;
|
|
||||||
if (caseCreate) {
|
|
||||||
try {
|
|
||||||
const result = await caseCreate({
|
|
||||||
Subject: payload.subject,
|
|
||||||
Description: payload.description,
|
|
||||||
Origin: "Portal",
|
|
||||||
AccountId: sfAccountId,
|
|
||||||
});
|
|
||||||
const id = (result as { id?: unknown })?.id;
|
|
||||||
if (typeof id === "string" && id.trim().length > 0) {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
"Failed to create Salesforce Case for eligibility request; falling back to Task",
|
|
||||||
{
|
|
||||||
sfAccountIdTail: sfAccountId.slice(-4),
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskCreate = this.sf.sobject("Task")?.create;
|
|
||||||
if (!taskCreate) {
|
|
||||||
throw new Error("Salesforce Case/Task create methods not available");
|
|
||||||
}
|
|
||||||
const result = await taskCreate({
|
|
||||||
Subject: payload.subject,
|
|
||||||
Description: payload.description,
|
|
||||||
WhatId: sfAccountId,
|
|
||||||
});
|
|
||||||
const id = (result as { id?: unknown })?.id;
|
|
||||||
if (typeof id !== "string" || id.trim().length === 0) {
|
|
||||||
throw new Error("Salesforce did not return a request id");
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateAccountEligibilityRequestState(
|
private async updateAccountEligibilityRequestState(
|
||||||
sfAccountId: string,
|
sfAccountId: string,
|
||||||
|
|||||||
@ -42,6 +42,7 @@ export class OrderFieldMapService {
|
|||||||
"CreatedDate",
|
"CreatedDate",
|
||||||
"LastModifiedDate",
|
"LastModifiedDate",
|
||||||
"Pricebook2Id",
|
"Pricebook2Id",
|
||||||
|
"OpportunityId", // Linked Opportunity for lifecycle tracking
|
||||||
order.activationType,
|
order.activationType,
|
||||||
order.activationStatus,
|
order.activationStatus,
|
||||||
order.activationScheduledAt,
|
order.activationScheduledAt,
|
||||||
|
|||||||
@ -0,0 +1,495 @@
|
|||||||
|
/**
|
||||||
|
* Opportunity Matching Service
|
||||||
|
*
|
||||||
|
* Resolves which Opportunity to use for orders based on matching rules.
|
||||||
|
* Handles finding existing Opportunities or creating new ones as needed.
|
||||||
|
*
|
||||||
|
* Uses existing Salesforce stages:
|
||||||
|
* - Introduction → Ready → Post Processing → Active → △Cancelling → 〇Cancelled
|
||||||
|
*
|
||||||
|
* Matching Rules:
|
||||||
|
* 1. If order already has opportunityId → use it directly
|
||||||
|
* 2. For Internet orders → find Introduction/Ready stage Opportunity or create new
|
||||||
|
* 3. For SIM orders → find open Opportunity for account or create new
|
||||||
|
* 4. For VPN orders → create new Opportunity (no matching)
|
||||||
|
*
|
||||||
|
* @see docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
|
import {
|
||||||
|
type OpportunityProductTypeValue,
|
||||||
|
type OpportunityStageValue,
|
||||||
|
type CancellationFormData,
|
||||||
|
type CancellationOpportunityData,
|
||||||
|
type CancellationEligibility,
|
||||||
|
OPPORTUNITY_STAGE,
|
||||||
|
OPPORTUNITY_SOURCE,
|
||||||
|
OPPORTUNITY_PRODUCT_TYPE,
|
||||||
|
APPLICATION_STAGE,
|
||||||
|
getCancellationEligibility,
|
||||||
|
validateCancellationMonth,
|
||||||
|
transformCancellationFormToOpportunityData,
|
||||||
|
} from "@customer-portal/domain/opportunity";
|
||||||
|
import type { OrderTypeValue } from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context for Opportunity resolution
|
||||||
|
*/
|
||||||
|
export interface OpportunityResolutionContext {
|
||||||
|
/** Salesforce Account ID */
|
||||||
|
accountId: string;
|
||||||
|
|
||||||
|
/** Order type (Internet, SIM, VPN) */
|
||||||
|
orderType: OrderTypeValue;
|
||||||
|
|
||||||
|
/** Existing Opportunity ID if provided with order */
|
||||||
|
existingOpportunityId?: string;
|
||||||
|
|
||||||
|
/** Source of the order (for new Opportunity creation) */
|
||||||
|
source?: "checkout" | "eligibility" | "order";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of Opportunity resolution
|
||||||
|
*/
|
||||||
|
export interface ResolvedOpportunity {
|
||||||
|
/** The Opportunity ID to use */
|
||||||
|
opportunityId: string;
|
||||||
|
|
||||||
|
/** Whether a new Opportunity was created */
|
||||||
|
wasCreated: boolean;
|
||||||
|
|
||||||
|
/** The stage of the Opportunity */
|
||||||
|
stage: OpportunityStageValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OpportunityMatchingService {
|
||||||
|
constructor(
|
||||||
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Opportunity Resolution for Orders
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Opportunity to use for an order
|
||||||
|
*
|
||||||
|
* This is the main entry point for Opportunity matching.
|
||||||
|
* It handles finding existing Opportunities or creating new ones.
|
||||||
|
*
|
||||||
|
* @param context - Resolution context with account and order details
|
||||||
|
* @returns Resolved Opportunity with ID and metadata
|
||||||
|
*/
|
||||||
|
async resolveOpportunityForOrder(
|
||||||
|
context: OpportunityResolutionContext
|
||||||
|
): Promise<ResolvedOpportunity> {
|
||||||
|
this.logger.log("Resolving Opportunity for order", {
|
||||||
|
accountId: context.accountId,
|
||||||
|
orderType: context.orderType,
|
||||||
|
hasExistingOpportunityId: !!context.existingOpportunityId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rule 1: If order already has opportunityId, validate and use it
|
||||||
|
if (context.existingOpportunityId) {
|
||||||
|
return this.useExistingOpportunity(context.existingOpportunityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2-4: Find or create based on order type
|
||||||
|
const productType = this.mapOrderTypeToProductType(context.orderType);
|
||||||
|
|
||||||
|
if (!productType) {
|
||||||
|
this.logger.warn("Unknown order type, creating new Opportunity", {
|
||||||
|
orderType: context.orderType,
|
||||||
|
});
|
||||||
|
return this.createNewOpportunity(context, "Internet");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find existing open Opportunity
|
||||||
|
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||||
|
context.accountId,
|
||||||
|
productType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingOppId) {
|
||||||
|
return this.useExistingOpportunity(existingOppId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No existing Opportunity found - create new one
|
||||||
|
return this.createNewOpportunity(context, productType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Opportunity Creation Triggers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Opportunity at eligibility request (Internet only)
|
||||||
|
*
|
||||||
|
* Called when customer requests Internet eligibility check.
|
||||||
|
* Creates an Opportunity in "Introduction" stage.
|
||||||
|
*
|
||||||
|
* NOTE: The Case is linked TO the Opportunity via Case.OpportunityId,
|
||||||
|
* not the other way around. So we don't need to store Case ID on Opportunity.
|
||||||
|
* NOTE: Opportunity Name is auto-generated by Salesforce workflow.
|
||||||
|
*
|
||||||
|
* @param accountId - Salesforce Account ID
|
||||||
|
* @returns Created Opportunity ID
|
||||||
|
*/
|
||||||
|
async createOpportunityForEligibility(accountId: string): Promise<string> {
|
||||||
|
this.logger.log("Creating Opportunity for Internet eligibility request", {
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opportunityId = await this.opportunityService.createOpportunity({
|
||||||
|
accountId,
|
||||||
|
productType: OPPORTUNITY_PRODUCT_TYPE.INTERNET,
|
||||||
|
stage: OPPORTUNITY_STAGE.INTRODUCTION,
|
||||||
|
source: OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY,
|
||||||
|
applicationStage: APPLICATION_STAGE.INTRO_1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return opportunityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Opportunity at checkout registration (SIM only)
|
||||||
|
*
|
||||||
|
* Called when customer creates account during SIM checkout.
|
||||||
|
* Creates an Opportunity in "Introduction" stage.
|
||||||
|
* NOTE: Opportunity Name is auto-generated by Salesforce workflow.
|
||||||
|
*
|
||||||
|
* @param accountId - Salesforce Account ID
|
||||||
|
* @returns Created Opportunity ID
|
||||||
|
*/
|
||||||
|
async createOpportunityForCheckoutRegistration(accountId: string): Promise<string> {
|
||||||
|
this.logger.log("Creating Opportunity for SIM checkout registration", {
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opportunityId = await this.opportunityService.createOpportunity({
|
||||||
|
accountId,
|
||||||
|
productType: OPPORTUNITY_PRODUCT_TYPE.SIM,
|
||||||
|
stage: OPPORTUNITY_STAGE.INTRODUCTION,
|
||||||
|
source: OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION,
|
||||||
|
applicationStage: APPLICATION_STAGE.INTRO_1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return opportunityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Lifecycle Stage Updates
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Opportunity stage to Ready (eligible)
|
||||||
|
*
|
||||||
|
* Called when Internet eligibility is confirmed.
|
||||||
|
*
|
||||||
|
* @param opportunityId - Opportunity ID to update
|
||||||
|
*/
|
||||||
|
async markEligible(opportunityId: string): Promise<void> {
|
||||||
|
this.logger.log("Marking Opportunity as eligible (Ready)", { opportunityId });
|
||||||
|
await this.opportunityService.updateStage(
|
||||||
|
opportunityId,
|
||||||
|
OPPORTUNITY_STAGE.READY,
|
||||||
|
"Eligibility confirmed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Opportunity stage to Void (not eligible)
|
||||||
|
*
|
||||||
|
* Called when Internet eligibility check fails.
|
||||||
|
*
|
||||||
|
* @param opportunityId - Opportunity ID to update
|
||||||
|
*/
|
||||||
|
async markNotEligible(opportunityId: string): Promise<void> {
|
||||||
|
this.logger.log("Marking Opportunity as not eligible (Void)", { opportunityId });
|
||||||
|
await this.opportunityService.updateStage(
|
||||||
|
opportunityId,
|
||||||
|
OPPORTUNITY_STAGE.VOID,
|
||||||
|
"Not eligible for service"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Opportunity stage after order placement
|
||||||
|
*
|
||||||
|
* Called after order is successfully created in Salesforce.
|
||||||
|
*
|
||||||
|
* @param opportunityId - Opportunity ID to update
|
||||||
|
*/
|
||||||
|
async markOrderPlaced(opportunityId: string): Promise<void> {
|
||||||
|
this.logger.log("Marking Opportunity as order placed (Post Processing)", { opportunityId });
|
||||||
|
await this.opportunityService.updateStage(
|
||||||
|
opportunityId,
|
||||||
|
OPPORTUNITY_STAGE.POST_PROCESSING,
|
||||||
|
"Order placed via portal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Opportunity stage after provisioning
|
||||||
|
*
|
||||||
|
* Called after order is successfully provisioned.
|
||||||
|
*
|
||||||
|
* @param opportunityId - Opportunity ID to update
|
||||||
|
* @param whmcsServiceId - WHMCS Service ID to link
|
||||||
|
*/
|
||||||
|
async markProvisioned(opportunityId: string, whmcsServiceId: number): Promise<void> {
|
||||||
|
this.logger.log("Marking Opportunity as active", {
|
||||||
|
opportunityId,
|
||||||
|
whmcsServiceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update stage to Active
|
||||||
|
await this.opportunityService.updateStage(
|
||||||
|
opportunityId,
|
||||||
|
OPPORTUNITY_STAGE.ACTIVE,
|
||||||
|
"Service provisioned successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link WHMCS Service ID for cancellation workflows
|
||||||
|
await this.opportunityService.linkWhmcsServiceToOpportunity(opportunityId, whmcsServiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Opportunity for eligibility result
|
||||||
|
*
|
||||||
|
* Called when eligibility check is completed (eligible or not).
|
||||||
|
*
|
||||||
|
* @param opportunityId - Opportunity ID to update
|
||||||
|
* @param isEligible - Whether the customer is eligible
|
||||||
|
*/
|
||||||
|
async updateEligibilityResult(opportunityId: string, isEligible: boolean): Promise<void> {
|
||||||
|
if (isEligible) {
|
||||||
|
await this.markEligible(opportunityId);
|
||||||
|
} else {
|
||||||
|
await this.markNotEligible(opportunityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Cancellation Flow
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cancellation eligibility for a customer
|
||||||
|
*
|
||||||
|
* Calculates available cancellation months based on the 25th rule.
|
||||||
|
*
|
||||||
|
* @returns Cancellation eligibility details
|
||||||
|
*/
|
||||||
|
getCancellationEligibility(): CancellationEligibility {
|
||||||
|
return getCancellationEligibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a cancellation request
|
||||||
|
*
|
||||||
|
* Checks:
|
||||||
|
* - Month format is valid
|
||||||
|
* - Month is not before earliest allowed
|
||||||
|
* - Both confirmations are checked
|
||||||
|
*
|
||||||
|
* @param formData - Form data from customer
|
||||||
|
* @returns Validation result
|
||||||
|
*/
|
||||||
|
validateCancellationRequest(formData: CancellationFormData): { valid: boolean; error?: string } {
|
||||||
|
// Validate month
|
||||||
|
const monthValidation = validateCancellationMonth(formData.cancellationMonth);
|
||||||
|
if (!monthValidation.valid) {
|
||||||
|
return monthValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate confirmations
|
||||||
|
if (!formData.confirmTermsRead) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "You must confirm you have read the cancellation terms",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.confirmMonthEndCancellation) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "You must confirm you understand cancellation is at month end",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a cancellation request
|
||||||
|
*
|
||||||
|
* Finds the Opportunity for the service and updates it with cancellation data.
|
||||||
|
*
|
||||||
|
* @param whmcsServiceId - WHMCS Service ID
|
||||||
|
* @param formData - Cancellation form data
|
||||||
|
* @returns Result of the cancellation update
|
||||||
|
*/
|
||||||
|
async processCancellationRequest(
|
||||||
|
whmcsServiceId: number,
|
||||||
|
formData: CancellationFormData
|
||||||
|
): Promise<{ success: boolean; opportunityId?: string; error?: string }> {
|
||||||
|
this.logger.log("Processing cancellation request", {
|
||||||
|
whmcsServiceId,
|
||||||
|
cancellationMonth: formData.cancellationMonth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate the request
|
||||||
|
const validation = this.validateCancellationRequest(formData);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return { success: false, error: validation.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the Opportunity
|
||||||
|
const opportunityId =
|
||||||
|
await this.opportunityService.findOpportunityByWhmcsServiceId(whmcsServiceId);
|
||||||
|
|
||||||
|
if (!opportunityId) {
|
||||||
|
this.logger.warn("No Opportunity found for WHMCS Service", { whmcsServiceId });
|
||||||
|
// This is not necessarily an error - older services may not have Opportunities
|
||||||
|
// Return success but note no Opportunity was found
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform form data to Opportunity data
|
||||||
|
const cancellationData: CancellationOpportunityData =
|
||||||
|
transformCancellationFormToOpportunityData(formData);
|
||||||
|
|
||||||
|
// Update the Opportunity
|
||||||
|
await this.opportunityService.updateCancellationData(opportunityId, cancellationData);
|
||||||
|
|
||||||
|
this.logger.log("Cancellation request processed successfully", {
|
||||||
|
opportunityId,
|
||||||
|
scheduledDate: cancellationData.scheduledCancellationDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, opportunityId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Private Helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use an existing Opportunity (validate it exists)
|
||||||
|
*/
|
||||||
|
private async useExistingOpportunity(opportunityId: string): Promise<ResolvedOpportunity> {
|
||||||
|
this.logger.debug("Using existing Opportunity", { opportunityId });
|
||||||
|
|
||||||
|
const opportunity = await this.opportunityService.getOpportunityById(opportunityId);
|
||||||
|
|
||||||
|
if (!opportunity) {
|
||||||
|
this.logger.warn("Existing Opportunity not found, will need to create new", {
|
||||||
|
opportunityId,
|
||||||
|
});
|
||||||
|
throw new Error(`Opportunity ${opportunityId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
opportunityId: opportunity.id,
|
||||||
|
wasCreated: false,
|
||||||
|
stage: opportunity.stage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new Opportunity for an order
|
||||||
|
*/
|
||||||
|
private async createNewOpportunity(
|
||||||
|
context: OpportunityResolutionContext,
|
||||||
|
productType: OpportunityProductTypeValue
|
||||||
|
): Promise<ResolvedOpportunity> {
|
||||||
|
this.logger.debug("Creating new Opportunity for order", {
|
||||||
|
accountId: context.accountId,
|
||||||
|
productType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stage = this.determineInitialStage(context);
|
||||||
|
const source = this.determineSource(context);
|
||||||
|
|
||||||
|
// Salesforce workflow auto-generates Opportunity Name
|
||||||
|
const opportunityId = await this.opportunityService.createOpportunity({
|
||||||
|
accountId: context.accountId,
|
||||||
|
productType,
|
||||||
|
stage,
|
||||||
|
source,
|
||||||
|
applicationStage: APPLICATION_STAGE.INTRO_1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
opportunityId,
|
||||||
|
wasCreated: true,
|
||||||
|
stage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map OrderType to OpportunityProductType
|
||||||
|
*/
|
||||||
|
private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue | null {
|
||||||
|
switch (orderType) {
|
||||||
|
case "Internet":
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
|
||||||
|
case "SIM":
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.SIM;
|
||||||
|
case "VPN":
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.VPN;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine initial stage for new Opportunity
|
||||||
|
*/
|
||||||
|
private determineInitialStage(context: OpportunityResolutionContext): OpportunityStageValue {
|
||||||
|
// If coming from eligibility request
|
||||||
|
if (context.source === "eligibility") {
|
||||||
|
return OPPORTUNITY_STAGE.INTRODUCTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If coming from checkout registration
|
||||||
|
if (context.source === "checkout") {
|
||||||
|
return OPPORTUNITY_STAGE.INTRODUCTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: order placement - go to Post Processing
|
||||||
|
return OPPORTUNITY_STAGE.POST_PROCESSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine source for new Opportunity
|
||||||
|
*/
|
||||||
|
private determineSource(
|
||||||
|
context: OpportunityResolutionContext
|
||||||
|
): (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE] {
|
||||||
|
switch (context.source) {
|
||||||
|
case "eligibility":
|
||||||
|
return OPPORTUNITY_SOURCE.INTERNET_ELIGIBILITY;
|
||||||
|
case "checkout":
|
||||||
|
return OPPORTUNITY_SOURCE.SIM_CHECKOUT_REGISTRATION;
|
||||||
|
case "order":
|
||||||
|
default:
|
||||||
|
return OPPORTUNITY_SOURCE.ORDER_PLACEMENT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||||
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
|
import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
|
||||||
import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
|
import type { WhmcsOrderResult } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
|
||||||
import { OrderOrchestrator } from "./order-orchestrator.service.js";
|
import { OrderOrchestrator } from "./order-orchestrator.service.js";
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
type OrderFulfillmentValidationResult,
|
type OrderFulfillmentValidationResult,
|
||||||
Providers as OrderProviders,
|
Providers as OrderProviders,
|
||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
|
import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity";
|
||||||
import {
|
import {
|
||||||
OrderValidationException,
|
OrderValidationException,
|
||||||
FulfillmentException,
|
FulfillmentException,
|
||||||
@ -51,6 +53,7 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
private readonly salesforceService: SalesforceService,
|
private readonly salesforceService: SalesforceService,
|
||||||
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
private readonly whmcsOrderService: WhmcsOrderService,
|
private readonly whmcsOrderService: WhmcsOrderService,
|
||||||
private readonly orderOrchestrator: OrderOrchestrator,
|
private readonly orderOrchestrator: OrderOrchestrator,
|
||||||
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
private readonly orderFulfillmentValidator: OrderFulfillmentValidator,
|
||||||
@ -232,12 +235,16 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
`Provisioned from Salesforce Order ${sfOrderId}`
|
`Provisioned from Salesforce Order ${sfOrderId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get OpportunityId from order details for WHMCS lifecycle linking
|
||||||
|
const sfOpportunityId = context.orderDetails?.opportunityId;
|
||||||
|
|
||||||
const result = await this.whmcsOrderService.addOrder({
|
const result = await this.whmcsOrderService.addOrder({
|
||||||
clientId: context.validation.clientId,
|
clientId: context.validation.clientId,
|
||||||
items: mappingResult.whmcsItems,
|
items: mappingResult.whmcsItems,
|
||||||
paymentMethod: "stripe",
|
paymentMethod: "stripe",
|
||||||
promoCode: "1st Month Free (Monthly Plan)",
|
promoCode: "1st Month Free (Monthly Plan)",
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
|
sfOpportunityId, // Pass to WHMCS for bidirectional linking
|
||||||
notes: orderNotes,
|
notes: orderNotes,
|
||||||
noinvoiceemail: true,
|
noinvoiceemail: true,
|
||||||
noemail: true,
|
noemail: true,
|
||||||
@ -346,6 +353,54 @@ export class OrderFulfillmentOrchestrator {
|
|||||||
},
|
},
|
||||||
critical: true,
|
critical: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "opportunity_update",
|
||||||
|
description: "Update Opportunity with WHMCS Service ID and Active stage",
|
||||||
|
execute: this.createTrackedStep(context, "opportunity_update", async () => {
|
||||||
|
const opportunityId = context.orderDetails?.opportunityId;
|
||||||
|
const serviceId = whmcsCreateResult?.serviceIds?.[0];
|
||||||
|
|
||||||
|
if (!opportunityId) {
|
||||||
|
this.logger.debug("No Opportunity linked to order, skipping update", {
|
||||||
|
sfOrderId,
|
||||||
|
});
|
||||||
|
return { skipped: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update Opportunity stage to Active and set WHMCS Service ID
|
||||||
|
await this.opportunityService.updateStage(
|
||||||
|
opportunityId,
|
||||||
|
OPPORTUNITY_STAGE.ACTIVE,
|
||||||
|
"Service activated via fulfillment"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (serviceId) {
|
||||||
|
await this.opportunityService.linkWhmcsServiceToOpportunity(
|
||||||
|
opportunityId,
|
||||||
|
serviceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log("Opportunity updated with Active stage and WHMCS link", {
|
||||||
|
opportunityIdTail: opportunityId.slice(-4),
|
||||||
|
whmcsServiceId: serviceId,
|
||||||
|
sfOrderId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { opportunityId, whmcsServiceId: serviceId };
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail - Opportunity update is non-critical
|
||||||
|
this.logger.warn("Failed to update Opportunity after fulfillment", {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
opportunityId,
|
||||||
|
sfOrderId,
|
||||||
|
});
|
||||||
|
return { failed: true as const, error: getErrorMessage(error) };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
critical: false, // Opportunity update failure shouldn't rollback fulfillment
|
||||||
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
description: `Order fulfillment for ${sfOrderId}`,
|
description: `Order fulfillment for ${sfOrderId}`,
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
import { Injectable, Inject, NotFoundException } from "@nestjs/common";
|
import { Injectable, Inject, NotFoundException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
|
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
|
||||||
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
import { OrderValidator } from "./order-validator.service.js";
|
import { OrderValidator } from "./order-validator.service.js";
|
||||||
import { OrderBuilder } from "./order-builder.service.js";
|
import { OrderBuilder } from "./order-builder.service.js";
|
||||||
import { OrderItemBuilder } from "./order-item-builder.service.js";
|
import { OrderItemBuilder } from "./order-item-builder.service.js";
|
||||||
import type { OrderItemCompositePayload } from "./order-item-builder.service.js";
|
import type { OrderItemCompositePayload } from "./order-item-builder.service.js";
|
||||||
import { OrdersCacheService } from "./orders-cache.service.js";
|
import { OrdersCacheService } from "./orders-cache.service.js";
|
||||||
import type { OrderDetails, OrderSummary } from "@customer-portal/domain/orders";
|
import type { OrderDetails, OrderSummary, OrderTypeValue } from "@customer-portal/domain/orders";
|
||||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||||
|
import {
|
||||||
|
OPPORTUNITY_STAGE,
|
||||||
|
OPPORTUNITY_SOURCE,
|
||||||
|
OPPORTUNITY_PRODUCT_TYPE,
|
||||||
|
type OpportunityProductTypeValue,
|
||||||
|
} from "@customer-portal/domain/opportunity";
|
||||||
|
|
||||||
type OrderDetailsResponse = OrderDetails;
|
type OrderDetailsResponse = OrderDetails;
|
||||||
type OrderSummaryResponse = OrderSummary;
|
type OrderSummaryResponse = OrderSummary;
|
||||||
@ -21,6 +28,7 @@ export class OrderOrchestrator {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
private readonly salesforceOrderService: SalesforceOrderService,
|
private readonly salesforceOrderService: SalesforceOrderService,
|
||||||
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
private readonly orderValidator: OrderValidator,
|
private readonly orderValidator: OrderValidator,
|
||||||
private readonly orderBuilder: OrderBuilder,
|
private readonly orderBuilder: OrderBuilder,
|
||||||
private readonly orderItemBuilder: OrderItemBuilder,
|
private readonly orderItemBuilder: OrderItemBuilder,
|
||||||
@ -46,9 +54,18 @@ export class OrderOrchestrator {
|
|||||||
"Order validation completed successfully"
|
"Order validation completed successfully"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2) Build order fields (includes address snapshot)
|
// 2) Resolve Opportunity for this order
|
||||||
|
const opportunityId = await this.resolveOpportunityForOrder(
|
||||||
|
validatedBody.orderType,
|
||||||
|
userMapping.sfAccountId ?? null,
|
||||||
|
validatedBody.opportunityId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3) Build order fields with Opportunity link
|
||||||
|
const bodyWithOpportunity = opportunityId ? { ...validatedBody, opportunityId } : validatedBody;
|
||||||
|
|
||||||
const orderFields = await this.orderBuilder.buildOrderFields(
|
const orderFields = await this.orderBuilder.buildOrderFields(
|
||||||
validatedBody,
|
bodyWithOpportunity,
|
||||||
userMapping,
|
userMapping,
|
||||||
pricebookId,
|
pricebookId,
|
||||||
validatedBody.userId
|
validatedBody.userId
|
||||||
@ -63,6 +80,7 @@ export class OrderOrchestrator {
|
|||||||
orderType: validatedBody.orderType,
|
orderType: validatedBody.orderType,
|
||||||
skuCount: validatedBody.skus.length,
|
skuCount: validatedBody.skus.length,
|
||||||
orderItemCount: orderItemsPayload.length,
|
orderItemCount: orderItemsPayload.length,
|
||||||
|
hasOpportunity: !!opportunityId,
|
||||||
},
|
},
|
||||||
"Order payload prepared"
|
"Order payload prepared"
|
||||||
);
|
);
|
||||||
@ -72,6 +90,27 @@ export class OrderOrchestrator {
|
|||||||
orderItemsPayload
|
orderItemsPayload
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4) Update Opportunity stage to Post Processing
|
||||||
|
if (opportunityId) {
|
||||||
|
try {
|
||||||
|
await this.opportunityService.updateStage(
|
||||||
|
opportunityId,
|
||||||
|
OPPORTUNITY_STAGE.POST_PROCESSING,
|
||||||
|
"Order placed via Portal"
|
||||||
|
);
|
||||||
|
this.logger.log("Opportunity stage updated to Post Processing", {
|
||||||
|
opportunityIdTail: opportunityId.slice(-4),
|
||||||
|
orderId: created.id,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Log but don't fail the order
|
||||||
|
this.logger.warn("Failed to update Opportunity stage after order", {
|
||||||
|
opportunityId,
|
||||||
|
orderId: created.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (userMapping.sfAccountId) {
|
if (userMapping.sfAccountId) {
|
||||||
await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId);
|
await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId);
|
||||||
}
|
}
|
||||||
@ -82,6 +121,7 @@ export class OrderOrchestrator {
|
|||||||
orderId: created.id,
|
orderId: created.id,
|
||||||
skuCount: validatedBody.skus.length,
|
skuCount: validatedBody.skus.length,
|
||||||
orderItemCount: orderItemsPayload.length,
|
orderItemCount: orderItemsPayload.length,
|
||||||
|
opportunityId,
|
||||||
},
|
},
|
||||||
"Order creation workflow completed successfully"
|
"Order creation workflow completed successfully"
|
||||||
);
|
);
|
||||||
@ -93,6 +133,90 @@ export class OrderOrchestrator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve Opportunity for an order
|
||||||
|
*
|
||||||
|
* - If order already has an Opportunity ID, use it
|
||||||
|
* - Otherwise, find existing open Opportunity for this product type
|
||||||
|
* - If none found, create a new one with Post Processing stage
|
||||||
|
*/
|
||||||
|
private async resolveOpportunityForOrder(
|
||||||
|
orderType: OrderTypeValue,
|
||||||
|
sfAccountId: string | null,
|
||||||
|
existingOpportunityId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
// If account ID is missing, can't create Opportunity
|
||||||
|
if (!sfAccountId) {
|
||||||
|
this.logger.warn("Cannot resolve Opportunity: no Salesforce Account ID");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeAccountId = assertSalesforceId(sfAccountId, "sfAccountId");
|
||||||
|
const productType = this.mapOrderTypeToProductType(orderType);
|
||||||
|
|
||||||
|
// If order already has Opportunity ID, use it
|
||||||
|
if (existingOpportunityId) {
|
||||||
|
this.logger.debug("Using existing Opportunity from order", {
|
||||||
|
opportunityId: existingOpportunityId,
|
||||||
|
});
|
||||||
|
return existingOpportunityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to find existing open Opportunity (Introduction or Ready stage)
|
||||||
|
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||||
|
safeAccountId,
|
||||||
|
productType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingOppId) {
|
||||||
|
this.logger.log("Found existing Opportunity for order", {
|
||||||
|
opportunityIdTail: existingOppId.slice(-4),
|
||||||
|
productType,
|
||||||
|
});
|
||||||
|
return existingOppId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new Opportunity - Salesforce workflow auto-generates the name
|
||||||
|
const newOppId = await this.opportunityService.createOpportunity({
|
||||||
|
accountId: safeAccountId,
|
||||||
|
productType,
|
||||||
|
stage: OPPORTUNITY_STAGE.POST_PROCESSING,
|
||||||
|
source: OPPORTUNITY_SOURCE.ORDER_PLACEMENT,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Created new Opportunity for order", {
|
||||||
|
opportunityIdTail: newOppId.slice(-4),
|
||||||
|
productType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newOppId;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn("Failed to resolve Opportunity for order", {
|
||||||
|
orderType,
|
||||||
|
accountIdTail: safeAccountId.slice(-4),
|
||||||
|
});
|
||||||
|
// Don't fail the order if Opportunity resolution fails
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map order type to Opportunity product type
|
||||||
|
*/
|
||||||
|
private mapOrderTypeToProductType(orderType: OrderTypeValue): OpportunityProductTypeValue {
|
||||||
|
switch (orderType) {
|
||||||
|
case "Internet":
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
|
||||||
|
case "SIM":
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.SIM;
|
||||||
|
case "VPN":
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.VPN;
|
||||||
|
default:
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.SIM; // Default fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get order by ID with order items
|
* Get order by ID with order items
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
/* eslint-env node */
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/* eslint-env node */
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bundle size monitoring script
|
* Bundle size monitoring script
|
||||||
* Analyzes bundle size and reports on performance metrics
|
* Analyzes bundle size and reports on performance metrics
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/* eslint-env node */
|
|
||||||
|
|
||||||
// Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors
|
// Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors
|
||||||
import { mkdirSync, existsSync, writeFileSync, rmSync } from "fs";
|
import { mkdirSync, existsSync, writeFileSync, rmSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/* eslint-env node */
|
|
||||||
|
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const Module = require("node:module");
|
const Module = require("node:module");
|
||||||
|
|||||||
@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/atoms/button";
|
||||||
|
import { SignupForm } from "../SignupForm/SignupForm";
|
||||||
|
import { LoginForm } from "../LoginForm/LoginForm";
|
||||||
|
import { LinkWhmcsForm } from "../LinkWhmcsForm/LinkWhmcsForm";
|
||||||
|
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||||
|
|
||||||
|
interface HighlightItem {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineAuthSectionProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
redirectTo?: string;
|
||||||
|
initialMode?: "signup" | "login";
|
||||||
|
highlights?: HighlightItem[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineAuthSection({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
redirectTo,
|
||||||
|
initialMode = "signup",
|
||||||
|
highlights = [],
|
||||||
|
className = "",
|
||||||
|
}: InlineAuthSectionProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [mode, setMode] = useState<"signup" | "login" | "migrate">(initialMode);
|
||||||
|
const safeRedirect = getSafeRedirect(redirectTo, "/account");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-muted/50 border border-border rounded-2xl p-6 md:p-8 ${className}`}>
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="inline-flex flex-wrap justify-center rounded-full border border-border bg-background p-1 shadow-[var(--cp-shadow-1)] gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={mode === "signup" ? "default" : "ghost"}
|
||||||
|
onClick={() => setMode("signup")}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={mode === "login" ? "default" : "ghost"}
|
||||||
|
onClick={() => setMode("login")}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={mode === "migrate" ? "default" : "ghost"}
|
||||||
|
onClick={() => setMode("migrate")}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
Migrate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{mode === "signup" && (
|
||||||
|
<SignupForm redirectTo={redirectTo} className="max-w-none" showFooterLinks={false} />
|
||||||
|
)}
|
||||||
|
{mode === "login" && (
|
||||||
|
<LoginForm redirectTo={redirectTo} showSignupLink={false} className="max-w-none" />
|
||||||
|
)}
|
||||||
|
{mode === "migrate" && (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-5 shadow-[var(--cp-shadow-1)]">
|
||||||
|
<h4 className="text-base font-semibold text-foreground mb-2">Migrate your account</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Use your legacy portal credentials to transfer your account.
|
||||||
|
</p>
|
||||||
|
<LinkWhmcsForm
|
||||||
|
onTransferred={result => {
|
||||||
|
if (result.needsPasswordSet) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
email: result.user.email,
|
||||||
|
redirect: safeRedirect,
|
||||||
|
});
|
||||||
|
router.push(`/auth/set-password?${params.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(safeRedirect);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{highlights.length > 0 && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-border">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
||||||
|
{highlights.map(item => (
|
||||||
|
<div key={item.title}>
|
||||||
|
<div className="text-sm font-medium text-foreground mb-1">{item.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{item.description}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ import { useLogin } from "../../hooks/use-auth";
|
|||||||
import { loginRequestSchema } from "@customer-portal/domain/auth";
|
import { loginRequestSchema } from "@customer-portal/domain/auth";
|
||||||
import { useZodForm } from "@/hooks/useZodForm";
|
import { useZodForm } from "@/hooks/useZodForm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
@ -22,6 +23,7 @@ interface LoginFormProps {
|
|||||||
showForgotPasswordLink?: boolean;
|
showForgotPasswordLink?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
redirectTo?: string;
|
redirectTo?: string;
|
||||||
|
initialEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,10 +45,13 @@ export function LoginForm({
|
|||||||
showForgotPasswordLink = true,
|
showForgotPasswordLink = true,
|
||||||
className = "",
|
className = "",
|
||||||
redirectTo,
|
redirectTo,
|
||||||
|
initialEmail,
|
||||||
}: LoginFormProps) {
|
}: LoginFormProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { login, loading, error, clearError } = useLogin();
|
const { login, loading, error, clearError } = useLogin({ redirectTo });
|
||||||
const redirect = redirectTo || searchParams?.get("next") || searchParams?.get("redirect");
|
const redirectCandidate =
|
||||||
|
redirectTo || searchParams?.get("next") || searchParams?.get("redirect");
|
||||||
|
const redirect = getSafeRedirect(redirectCandidate, "");
|
||||||
const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
|
const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
|
||||||
|
|
||||||
const handleLogin = useCallback(
|
const handleLogin = useCallback(
|
||||||
@ -70,7 +75,7 @@ export function LoginForm({
|
|||||||
useZodForm<LoginFormValues>({
|
useZodForm<LoginFormValues>({
|
||||||
schema: loginFormSchema,
|
schema: loginFormSchema,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
email: initialEmail ?? "",
|
||||||
password: "",
|
password: "",
|
||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -14,12 +14,12 @@ import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/a
|
|||||||
import { addressFormSchema } from "@customer-portal/domain/customer";
|
import { addressFormSchema } from "@customer-portal/domain/customer";
|
||||||
import { useZodForm } from "@/hooks/useZodForm";
|
import { useZodForm } from "@/hooks/useZodForm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { getSafeRedirect } from "@/features/auth/utils/route-protection";
|
||||||
|
|
||||||
import { MultiStepForm } from "./MultiStepForm";
|
import { MultiStepForm } from "./MultiStepForm";
|
||||||
import { AccountStep } from "./steps/AccountStep";
|
import { AccountStep } from "./steps/AccountStep";
|
||||||
import { AddressStep } from "./steps/AddressStep";
|
import { AddressStep } from "./steps/AddressStep";
|
||||||
import { PasswordStep } from "./steps/PasswordStep";
|
import { PasswordStep } from "./steps/PasswordStep";
|
||||||
import { ReviewStep } from "./steps/ReviewStep";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Frontend signup form schema
|
* Frontend signup form schema
|
||||||
@ -51,6 +51,8 @@ interface SignupFormProps {
|
|||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
redirectTo?: string;
|
redirectTo?: string;
|
||||||
|
initialEmail?: string;
|
||||||
|
showFooterLinks?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
@ -65,22 +67,16 @@ const STEPS = [
|
|||||||
description: "Used for service eligibility and delivery",
|
description: "Used for service eligibility and delivery",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "password",
|
key: "security",
|
||||||
title: "Create Password",
|
title: "Security & Terms",
|
||||||
description: "Secure your account",
|
description: "Create a password and confirm agreements",
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "review",
|
|
||||||
title: "Review & Confirm",
|
|
||||||
description: "Verify your information",
|
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = {
|
const STEP_FIELD_KEYS: Record<(typeof STEPS)[number]["key"], Array<keyof SignupFormData>> = {
|
||||||
account: ["firstName", "lastName", "email", "phone", "phoneCountryCode", "dateOfBirth", "gender"],
|
account: ["firstName", "lastName", "email", "phone", "phoneCountryCode", "dateOfBirth", "gender"],
|
||||||
address: ["address"],
|
address: ["address"],
|
||||||
password: ["password", "confirmPassword"],
|
security: ["password", "confirmPassword", "acceptTerms", "marketingConsent"],
|
||||||
review: ["acceptTerms"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = {
|
const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAny | undefined> = {
|
||||||
@ -96,18 +92,15 @@ const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAn
|
|||||||
address: signupFormBaseSchema.pick({
|
address: signupFormBaseSchema.pick({
|
||||||
address: true,
|
address: true,
|
||||||
}),
|
}),
|
||||||
password: signupFormBaseSchema
|
security: signupFormBaseSchema
|
||||||
.pick({
|
.pick({
|
||||||
password: true,
|
password: true,
|
||||||
confirmPassword: true,
|
confirmPassword: true,
|
||||||
|
acceptTerms: true,
|
||||||
})
|
})
|
||||||
.refine(data => data.password === data.confirmPassword, {
|
.refine(data => data.password === data.confirmPassword, {
|
||||||
message: "Passwords do not match",
|
message: "Passwords do not match",
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
}),
|
|
||||||
review: signupFormBaseSchema
|
|
||||||
.pick({
|
|
||||||
acceptTerms: true,
|
|
||||||
})
|
})
|
||||||
.refine(data => data.acceptTerms === true, {
|
.refine(data => data.acceptTerms === true, {
|
||||||
message: "You must accept the terms and conditions",
|
message: "You must accept the terms and conditions",
|
||||||
@ -115,12 +108,19 @@ const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAn
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SignupForm({ onSuccess, onError, className = "", redirectTo }: SignupFormProps) {
|
export function SignupForm({
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
className = "",
|
||||||
|
redirectTo,
|
||||||
|
initialEmail,
|
||||||
|
showFooterLinks = true,
|
||||||
|
}: SignupFormProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { signup, loading, error, clearError } = useSignupWithRedirect({ redirectTo });
|
const { signup, loading, error, clearError } = useSignupWithRedirect({ redirectTo });
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const redirectFromQuery = searchParams?.get("next") || searchParams?.get("redirect");
|
const redirectFromQuery = searchParams?.get("next") || searchParams?.get("redirect");
|
||||||
const redirect = redirectTo || redirectFromQuery;
|
const redirect = getSafeRedirect(redirectTo || redirectFromQuery, "");
|
||||||
const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
|
const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
|
||||||
|
|
||||||
const form = useZodForm<SignupFormData>({
|
const form = useZodForm<SignupFormData>({
|
||||||
@ -128,7 +128,7 @@ export function SignupForm({ onSuccess, onError, className = "", redirectTo }: S
|
|||||||
initialValues: {
|
initialValues: {
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: initialEmail ?? "",
|
||||||
phone: "",
|
phone: "",
|
||||||
phoneCountryCode: "+81",
|
phoneCountryCode: "+81",
|
||||||
company: "",
|
company: "",
|
||||||
@ -248,8 +248,7 @@ export function SignupForm({ onSuccess, onError, className = "", redirectTo }: S
|
|||||||
const stepContent = [
|
const stepContent = [
|
||||||
<AccountStep key="account" form={formProps} />,
|
<AccountStep key="account" form={formProps} />,
|
||||||
<AddressStep key="address" form={formProps} />,
|
<AddressStep key="address" form={formProps} />,
|
||||||
<PasswordStep key="password" form={formProps} />,
|
<PasswordStep key="security" form={formProps} />,
|
||||||
<ReviewStep key="review" form={formProps} />,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const steps = STEPS.map((s, i) => ({
|
const steps = STEPS.map((s, i) => ({
|
||||||
@ -276,26 +275,28 @@ export function SignupForm({ onSuccess, onError, className = "", redirectTo }: S
|
|||||||
</ErrorMessage>
|
</ErrorMessage>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 text-center border-t border-border pt-6 space-y-3">
|
{showFooterLinks && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="mt-6 text-center border-t border-border pt-6 space-y-3">
|
||||||
Already have an account?{" "}
|
<p className="text-sm text-muted-foreground">
|
||||||
<Link
|
Already have an account?{" "}
|
||||||
href={`/auth/login${redirectQuery}`}
|
<Link
|
||||||
className="font-medium text-primary hover:underline transition-colors"
|
href={`/auth/login${redirectQuery}`}
|
||||||
>
|
className="font-medium text-primary hover:underline transition-colors"
|
||||||
Sign in
|
>
|
||||||
</Link>
|
Sign in
|
||||||
</p>
|
</Link>
|
||||||
<p className="text-sm text-muted-foreground">
|
</p>
|
||||||
Existing customer?{" "}
|
<p className="text-sm text-muted-foreground">
|
||||||
<Link
|
Existing customer?{" "}
|
||||||
href="/auth/link-whmcs"
|
<Link
|
||||||
className="font-medium text-primary hover:underline transition-colors"
|
href="/auth/link-whmcs"
|
||||||
>
|
className="font-medium text-primary hover:underline transition-colors"
|
||||||
Migrate your account
|
>
|
||||||
</Link>
|
Migrate your account
|
||||||
</p>
|
</Link>
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { Input } from "@/components/atoms";
|
import { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
|
|
||||||
@ -29,6 +30,7 @@ interface AccountStepProps {
|
|||||||
export function AccountStep({ form }: AccountStepProps) {
|
export function AccountStep({ form }: AccountStepProps) {
|
||||||
const { values, errors, touched, setValue, setTouchedField } = form;
|
const { values, errors, touched, setValue, setTouchedField } = form;
|
||||||
const getError = (field: string) => (touched[field] ? errors[field] : undefined);
|
const getError = (field: string) => (touched[field] ? errors[field] : undefined);
|
||||||
|
const [showOptional, setShowOptional] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@ -110,55 +112,69 @@ export function AccountStep({ form }: AccountStepProps) {
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* DOB + Gender (Optional WHMCS custom fields) */}
|
<div className="pt-2">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<button
|
||||||
<FormField label="Date of Birth" error={getError("dateOfBirth")} helperText="Optional">
|
type="button"
|
||||||
<Input
|
className="text-sm font-medium text-primary hover:underline"
|
||||||
name="bday"
|
onClick={() => setShowOptional(s => !s)}
|
||||||
type="date"
|
>
|
||||||
value={values.dateOfBirth ?? ""}
|
{showOptional ? "Hide optional details" : "Add optional details"}
|
||||||
onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
|
</button>
|
||||||
onBlur={() => setTouchedField("dateOfBirth")}
|
|
||||||
autoComplete="bday"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Gender" error={getError("gender")} helperText="Optional">
|
|
||||||
<select
|
|
||||||
name="sex"
|
|
||||||
value={values.gender ?? ""}
|
|
||||||
onChange={e => setValue("gender", e.target.value || undefined)}
|
|
||||||
onBlur={() => setTouchedField("gender")}
|
|
||||||
className={[
|
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm",
|
|
||||||
"ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none",
|
|
||||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
getError("gender")
|
|
||||||
? "border-danger focus-visible:ring-danger focus-visible:ring-offset-2"
|
|
||||||
: "",
|
|
||||||
].join(" ")}
|
|
||||||
aria-invalid={Boolean(getError("gender")) || undefined}
|
|
||||||
>
|
|
||||||
<option value="">Select…</option>
|
|
||||||
<option value="male">Male</option>
|
|
||||||
<option value="female">Female</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company (Optional) */}
|
{showOptional && (
|
||||||
<FormField label="Company" error={getError("company")} helperText="Optional">
|
<div className="space-y-5">
|
||||||
<Input
|
{/* DOB + Gender (Optional WHMCS custom fields) */}
|
||||||
name="organization"
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
value={values.company ?? ""}
|
<FormField label="Date of Birth" error={getError("dateOfBirth")} helperText="Optional">
|
||||||
onChange={e => setValue("company", e.target.value)}
|
<Input
|
||||||
onBlur={() => setTouchedField("company")}
|
name="bday"
|
||||||
placeholder="Company name"
|
type="date"
|
||||||
autoComplete="organization"
|
value={values.dateOfBirth ?? ""}
|
||||||
/>
|
onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
|
||||||
</FormField>
|
onBlur={() => setTouchedField("dateOfBirth")}
|
||||||
|
autoComplete="bday"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Gender" error={getError("gender")} helperText="Optional">
|
||||||
|
<select
|
||||||
|
name="sex"
|
||||||
|
value={values.gender ?? ""}
|
||||||
|
onChange={e => setValue("gender", e.target.value || undefined)}
|
||||||
|
onBlur={() => setTouchedField("gender")}
|
||||||
|
className={[
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-sm",
|
||||||
|
"ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
getError("gender")
|
||||||
|
? "border-danger focus-visible:ring-danger focus-visible:ring-offset-2"
|
||||||
|
: "",
|
||||||
|
].join(" ")}
|
||||||
|
aria-invalid={Boolean(getError("gender")) || undefined}
|
||||||
|
>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
<option value="male">Male</option>
|
||||||
|
<option value="female">Female</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company (Optional) */}
|
||||||
|
<FormField label="Company" error={getError("company")} helperText="Optional">
|
||||||
|
<Input
|
||||||
|
name="organization"
|
||||||
|
value={values.company ?? ""}
|
||||||
|
onChange={e => setValue("company", e.target.value)}
|
||||||
|
onBlur={() => setTouchedField("company")}
|
||||||
|
placeholder="Company name"
|
||||||
|
autoComplete="organization"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,20 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { Input } from "@/components/atoms";
|
import { Input } from "@/components/atoms";
|
||||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||||
import { checkPasswordStrength, getPasswordStrengthDisplay } from "@customer-portal/domain/auth";
|
import { checkPasswordStrength, getPasswordStrengthDisplay } from "@customer-portal/domain/auth";
|
||||||
|
|
||||||
interface PasswordStepProps {
|
interface PasswordStepProps {
|
||||||
form: {
|
form: {
|
||||||
values: { email: string; password: string; confirmPassword: string };
|
values: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
acceptTerms: boolean;
|
||||||
|
marketingConsent?: boolean;
|
||||||
|
};
|
||||||
errors: Record<string, string | undefined>;
|
errors: Record<string, string | undefined>;
|
||||||
touched: Record<string, boolean | undefined>;
|
touched: Record<string, boolean | undefined>;
|
||||||
setValue: (field: string, value: unknown) => void;
|
setValue: (field: string, value: unknown) => void;
|
||||||
@ -99,6 +106,55 @@ export function PasswordStep({ form }: PasswordStepProps) {
|
|||||||
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
|
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-xl border border-border bg-muted/30 p-4">
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={values.acceptTerms}
|
||||||
|
onChange={e => setValue("acceptTerms", e.target.checked)}
|
||||||
|
onBlur={() => setTouchedField("acceptTerms")}
|
||||||
|
className="mt-0.5 h-5 w-5 text-primary border-input rounded focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground/80">
|
||||||
|
I accept the{" "}
|
||||||
|
<Link
|
||||||
|
href="/terms"
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-primary hover:underline font-medium"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
<span className="text-red-500 ml-1">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{touched.acceptTerms && errors.acceptTerms && (
|
||||||
|
<p className="text-sm text-danger ml-8">{errors.acceptTerms}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={values.marketingConsent ?? false}
|
||||||
|
onChange={e => setValue("marketingConsent", e.target.checked)}
|
||||||
|
className="mt-0.5 h-5 w-5 text-primary border-input rounded focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground/80">
|
||||||
|
Send me updates about new products and promotions
|
||||||
|
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||||
|
You can unsubscribe anytime
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,4 +8,5 @@ export { SignupForm } from "./SignupForm/SignupForm";
|
|||||||
export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm";
|
export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm";
|
||||||
export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm";
|
export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm";
|
||||||
export { LinkWhmcsForm } from "./LinkWhmcsForm/LinkWhmcsForm";
|
export { LinkWhmcsForm } from "./LinkWhmcsForm/LinkWhmcsForm";
|
||||||
|
export { InlineAuthSection } from "./InlineAuthSection/InlineAuthSection";
|
||||||
export { AuthLayout } from "@/components/templates/AuthLayout";
|
export { AuthLayout } from "@/components/templates/AuthLayout";
|
||||||
|
|||||||
@ -41,10 +41,10 @@ export function useAuth() {
|
|||||||
|
|
||||||
// Enhanced login with redirect handling
|
// Enhanced login with redirect handling
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (credentials: LoginRequest) => {
|
async (credentials: LoginRequest, options?: { redirectTo?: string }) => {
|
||||||
await loginAction(credentials);
|
await loginAction(credentials);
|
||||||
// Keep loading state active during redirect
|
// Keep loading state active during redirect
|
||||||
const redirectTo = getPostLoginRedirect(searchParams);
|
const redirectTo = getPostLoginRedirect(searchParams, options?.redirectTo);
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
// Note: loading will be cleared when the new page loads
|
// Note: loading will be cleared when the new page loads
|
||||||
},
|
},
|
||||||
@ -55,7 +55,7 @@ export function useAuth() {
|
|||||||
const signup = useCallback(
|
const signup = useCallback(
|
||||||
async (data: SignupRequest, options?: { redirectTo?: string }) => {
|
async (data: SignupRequest, options?: { redirectTo?: string }) => {
|
||||||
await signupAction(data);
|
await signupAction(data);
|
||||||
const dest = options?.redirectTo ?? getPostLoginRedirect(searchParams);
|
const dest = getPostLoginRedirect(searchParams, options?.redirectTo);
|
||||||
router.push(dest);
|
router.push(dest);
|
||||||
},
|
},
|
||||||
[signupAction, router, searchParams]
|
[signupAction, router, searchParams]
|
||||||
@ -100,11 +100,11 @@ export function useAuth() {
|
|||||||
/**
|
/**
|
||||||
* Hook for login functionality
|
* Hook for login functionality
|
||||||
*/
|
*/
|
||||||
export function useLogin() {
|
export function useLogin(options?: { redirectTo?: string }) {
|
||||||
const { login, loading, error, clearError } = useAuth();
|
const { login, loading, error, clearError } = useAuth();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
login,
|
login: (credentials: LoginRequest) => login(credentials, options),
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
clearError,
|
clearError,
|
||||||
|
|||||||
@ -1,8 +1,18 @@
|
|||||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||||
|
|
||||||
export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string {
|
export function getSafeRedirect(candidate?: string | null, fallback = "/account"): string {
|
||||||
const dest = searchParams.get("next") || searchParams.get("redirect") || "/account";
|
const dest = (candidate ?? "").trim();
|
||||||
// prevent open redirects
|
if (!dest) return fallback;
|
||||||
if (dest.startsWith("http://") || dest.startsWith("https://")) return "/account";
|
if (!dest.startsWith("/")) return fallback;
|
||||||
|
if (dest.startsWith("//")) return fallback;
|
||||||
|
if (dest.startsWith("http://") || dest.startsWith("https://")) return fallback;
|
||||||
return dest;
|
return dest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPostLoginRedirect(
|
||||||
|
searchParams: ReadonlyURLSearchParams,
|
||||||
|
override?: string | null
|
||||||
|
): string {
|
||||||
|
const candidate = override || searchParams.get("next") || searchParams.get("redirect");
|
||||||
|
return getSafeRedirect(candidate, "/account");
|
||||||
|
}
|
||||||
|
|||||||
@ -246,9 +246,13 @@ export function AddressConfirmation({
|
|||||||
? (getCountryName(address.country) ?? address.country)
|
? (getCountryName(address.country) ?? address.country)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const showConfirmAction =
|
||||||
|
isInternetOrder && !addressConfirmed && !editing && billingInfo.isComplete;
|
||||||
|
const showEditAction = billingInfo.isComplete && !editing;
|
||||||
|
|
||||||
return wrap(
|
return wrap(
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<MapPinIcon className="h-5 w-5 text-primary" />
|
<MapPinIcon className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
@ -261,12 +265,37 @@ export function AddressConfirmation({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||||
{/* Consistent status pill placement (right side) */}
|
|
||||||
<StatusPill
|
<StatusPill
|
||||||
label={statusVariant === "success" ? "Verified" : "Pending confirmation"}
|
label={statusVariant === "success" ? "Verified" : "Pending confirmation"}
|
||||||
variant={statusVariant}
|
variant={statusVariant}
|
||||||
/>
|
/>
|
||||||
|
{showConfirmAction ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConfirmAddress}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm Address
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{showEditAction ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEdit}
|
||||||
|
leftIcon={<PencilIcon className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
Edit Address
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -415,38 +444,6 @@ export function AddressConfirmation({
|
|||||||
Please confirm this is the correct installation address for your internet service.
|
Please confirm this is the correct installation address for your internet service.
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center gap-3 pt-4 border-t border-border">
|
|
||||||
{/* Primary action when pending for Internet orders */}
|
|
||||||
{isInternetOrder && !addressConfirmed && !editing && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleConfirmAddress}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm Address
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit button */}
|
|
||||||
{billingInfo.isComplete && !editing && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleEdit}
|
|
||||||
leftIcon={<PencilIcon className="h-4 w-4" />}
|
|
||||||
>
|
|
||||||
Edit Address
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
|
|||||||
@ -27,6 +27,14 @@ interface InternetPlanCardProps {
|
|||||||
action?: { label: string; href: string };
|
action?: { label: string; href: string };
|
||||||
/** Optional small prefix above pricing (e.g. "Starting from") */
|
/** Optional small prefix above pricing (e.g. "Starting from") */
|
||||||
pricingPrefix?: string;
|
pricingPrefix?: string;
|
||||||
|
/** Show tier badge (default: true) */
|
||||||
|
showTierBadge?: boolean;
|
||||||
|
/** Show plan subtitle (default: true) */
|
||||||
|
showPlanSubtitle?: boolean;
|
||||||
|
/** Show features list (default: true) */
|
||||||
|
showFeatures?: boolean;
|
||||||
|
/** Prefer which label becomes the title when details exist */
|
||||||
|
titlePriority?: "detail" | "base";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier-based styling using design tokens
|
// Tier-based styling using design tokens
|
||||||
@ -57,6 +65,10 @@ export function InternetPlanCard({
|
|||||||
configureHref,
|
configureHref,
|
||||||
action,
|
action,
|
||||||
pricingPrefix,
|
pricingPrefix,
|
||||||
|
showTierBadge = true,
|
||||||
|
showPlanSubtitle = true,
|
||||||
|
showFeatures = true,
|
||||||
|
titlePriority = "detail",
|
||||||
}: InternetPlanCardProps) {
|
}: InternetPlanCardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const shopBasePath = useShopBasePath();
|
const shopBasePath = useShopBasePath();
|
||||||
@ -66,6 +78,11 @@ export function InternetPlanCard({
|
|||||||
const isSilver = tier === "Silver";
|
const isSilver = tier === "Silver";
|
||||||
const isDisabled = disabled && !IS_DEVELOPMENT;
|
const isDisabled = disabled && !IS_DEVELOPMENT;
|
||||||
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
|
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
|
||||||
|
const hasDetail = Boolean(planDetail);
|
||||||
|
const showDetailAsTitle = titlePriority === "detail" && hasDetail;
|
||||||
|
const planTitle = showDetailAsTitle ? planDetail : planBaseName;
|
||||||
|
const planSubtitle = showDetailAsTitle ? planBaseName : hasDetail ? planDetail : null;
|
||||||
|
const planDescription = plan.catalogMetadata?.tierDescription || plan.description || null;
|
||||||
|
|
||||||
const installationPrices = installations
|
const installationPrices = installations
|
||||||
.map(installation => {
|
.map(installation => {
|
||||||
@ -163,24 +180,30 @@ export function InternetPlanCard({
|
|||||||
{/* Header with badges */}
|
{/* Header with badges */}
|
||||||
<div className={`flex flex-col gap-3 pb-4 border-b ${tierStyle.border}`}>
|
<div className={`flex flex-col gap-3 pb-4 border-b ${tierStyle.border}`}>
|
||||||
<div className="inline-flex flex-wrap items-center gap-2 text-sm">
|
<div className="inline-flex flex-wrap items-center gap-2 text-sm">
|
||||||
<CardBadge
|
{showTierBadge && (
|
||||||
text={plan.internetPlanTier ?? "Plan"}
|
<CardBadge
|
||||||
variant={getTierBadgeVariant()}
|
text={plan.internetPlanTier ?? "Plan"}
|
||||||
size="sm"
|
variant={getTierBadgeVariant()}
|
||||||
/>
|
size="sm"
|
||||||
{isGold && <CardBadge text="Recommended" variant="recommended" size="xs" />}
|
/>
|
||||||
{planDetail && <CardBadge text={planDetail} variant="family" size="xs" />}
|
)}
|
||||||
|
{showTierBadge && isGold && (
|
||||||
|
<CardBadge text="Recommended" variant="recommended" size="xs" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plan name and description - Full width */}
|
{/* Plan name and description - Full width */}
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
<h3 className="text-xl sm:text-2xl font-bold text-foreground leading-tight">
|
<h3 className="text-xl sm:text-2xl font-bold text-foreground leading-tight">
|
||||||
{planBaseName}
|
{planTitle}
|
||||||
</h3>
|
</h3>
|
||||||
{plan.catalogMetadata?.tierDescription || plan.description ? (
|
{showPlanSubtitle && planSubtitle && (
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
{plan.catalogMetadata?.tierDescription || plan.description}
|
{planSubtitle}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
{planDescription ? (
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">{planDescription}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -201,12 +224,14 @@ export function InternetPlanCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<div className="flex-grow pt-1">
|
{showFeatures && (
|
||||||
<h4 className="font-semibold text-foreground mb-4 text-sm uppercase tracking-wide">
|
<div className="flex-grow pt-1">
|
||||||
Your Plan Includes:
|
<h4 className="font-semibold text-foreground mb-4 text-sm uppercase tracking-wide">
|
||||||
</h4>
|
Your Plan Includes:
|
||||||
<ul className="space-y-3 text-sm">{renderPlanFeatures()}</ul>
|
</h4>
|
||||||
</div>
|
<ul className="space-y-3 text-sm">{renderPlanFeatures()}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Action Button */}
|
{/* Action Button */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
GlobeAltIcon,
|
GlobeAltIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard";
|
import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard";
|
||||||
import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
|
import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
|
||||||
@ -37,6 +38,35 @@ export function CatalogHomeView() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/40 border border-border rounded-2xl p-6 mb-10">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Pick a service",
|
||||||
|
description: "Internet, SIM, or VPN based on your needs.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Confirm availability",
|
||||||
|
description: "We verify your address for internet plans.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Configure and order",
|
||||||
|
description: "Choose options and complete checkout.",
|
||||||
|
},
|
||||||
|
].map(step => (
|
||||||
|
<div key={step.title} className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<CheckCircleIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-foreground">{step.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{step.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
|
||||||
<ServiceHeroCard
|
<ServiceHeroCard
|
||||||
title="Internet Service"
|
title="Internet Service"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { WifiIcon, ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline";
|
import { WifiIcon, ServerIcon, HomeIcon, BuildingOfficeIcon } from "@heroicons/react/24/outline";
|
||||||
import { useInternetCatalog } from "@/features/catalog/hooks";
|
import { useInternetCatalog } from "@/features/catalog/hooks";
|
||||||
@ -24,12 +25,18 @@ import {
|
|||||||
} from "@/features/catalog/hooks";
|
} from "@/features/catalog/hooks";
|
||||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
|
type AutoRequestStatus = "idle" | "submitting" | "submitted" | "failed" | "missing_address";
|
||||||
|
|
||||||
export function InternetPlansContainer() {
|
export function InternetPlansContainer() {
|
||||||
const shopBasePath = useShopBasePath();
|
const shopBasePath = useShopBasePath();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { user } = useAuthSession();
|
const { user } = useAuthSession();
|
||||||
const { data, isLoading, error } = useInternetCatalog();
|
const { data, isLoading, error } = useInternetCatalog();
|
||||||
const eligibilityQuery = useInternetEligibility();
|
const eligibilityQuery = useInternetEligibility();
|
||||||
|
const eligibilityLoading = eligibilityQuery.isLoading;
|
||||||
|
const refetchEligibility = eligibilityQuery.refetch;
|
||||||
const eligibilityRequest = useRequestInternetEligibilityCheck();
|
const eligibilityRequest = useRequestInternetEligibilityCheck();
|
||||||
|
const submitEligibilityRequest = eligibilityRequest.mutateAsync;
|
||||||
const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||||
const installations: InternetInstallationCatalogItem[] = useMemo(
|
const installations: InternetInstallationCatalogItem[] = useMemo(
|
||||||
() => data?.installations ?? [],
|
() => data?.installations ?? [],
|
||||||
@ -69,6 +76,10 @@ export function InternetPlansContainer() {
|
|||||||
user?.address?.postcode &&
|
user?.address?.postcode &&
|
||||||
(user?.address?.country || user?.address?.countryCode)
|
(user?.address?.country || user?.address?.countryCode)
|
||||||
);
|
);
|
||||||
|
const autoEligibilityRequest = searchParams?.get("autoEligibilityRequest") === "1";
|
||||||
|
const autoPlanSku = searchParams?.get("planSku");
|
||||||
|
const [autoRequestStatus, setAutoRequestStatus] = useState<AutoRequestStatus>("idle");
|
||||||
|
const [autoRequestId, setAutoRequestId] = useState<string | null>(null);
|
||||||
const addressLabel = useMemo(() => {
|
const addressLabel = useMemo(() => {
|
||||||
const a = user?.address;
|
const a = user?.address;
|
||||||
if (!a) return "";
|
if (!a) return "";
|
||||||
@ -84,6 +95,61 @@ export function InternetPlansContainer() {
|
|||||||
return eligibilityValue.trim();
|
return eligibilityValue.trim();
|
||||||
}, [eligibilityValue, isEligible]);
|
}, [eligibilityValue, isEligible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoEligibilityRequest) return;
|
||||||
|
if (autoRequestStatus !== "idle") return;
|
||||||
|
if (eligibilityLoading) return;
|
||||||
|
if (!isNotRequested) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.history.replaceState(null, "", `${shopBasePath}/internet`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasServiceAddress) {
|
||||||
|
setAutoRequestStatus("missing_address");
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.history.replaceState(null, "", `${shopBasePath}/internet`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setAutoRequestStatus("submitting");
|
||||||
|
try {
|
||||||
|
const notes = autoPlanSku
|
||||||
|
? `Requested after signup. Selected plan SKU: ${autoPlanSku}`
|
||||||
|
: "Requested after signup.";
|
||||||
|
const result = await submitEligibilityRequest({
|
||||||
|
address: user?.address ?? undefined,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
setAutoRequestId(result.requestId ?? null);
|
||||||
|
setAutoRequestStatus("submitted");
|
||||||
|
await refetchEligibility();
|
||||||
|
} catch (err) {
|
||||||
|
void err;
|
||||||
|
setAutoRequestStatus("failed");
|
||||||
|
} finally {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.history.replaceState(null, "", `${shopBasePath}/internet`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void submit();
|
||||||
|
}, [
|
||||||
|
autoEligibilityRequest,
|
||||||
|
autoPlanSku,
|
||||||
|
autoRequestStatus,
|
||||||
|
eligibilityLoading,
|
||||||
|
refetchEligibility,
|
||||||
|
submitEligibilityRequest,
|
||||||
|
hasServiceAddress,
|
||||||
|
isNotRequested,
|
||||||
|
shopBasePath,
|
||||||
|
user?.address,
|
||||||
|
]);
|
||||||
|
|
||||||
const getEligibilityIcon = (offeringType?: string) => {
|
const getEligibilityIcon = (offeringType?: string) => {
|
||||||
const lower = (offeringType || "").toLowerCase();
|
const lower = (offeringType || "").toLowerCase();
|
||||||
if (lower.includes("home")) return <HomeIcon className="h-5 w-5" />;
|
if (lower.includes("home")) return <HomeIcon className="h-5 w-5" />;
|
||||||
@ -165,16 +231,36 @@ export function InternetPlansContainer() {
|
|||||||
<CatalogBackLink href={shopBasePath} label="Back to Services" />
|
<CatalogBackLink href={shopBasePath} label="Back to Services" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="Choose Your Internet Plan"
|
title="Choose Your Internet Type"
|
||||||
description="High-speed fiber internet with reliable connectivity for your home or business."
|
description="Compare apartment vs home options and pick the speed that fits your address."
|
||||||
>
|
>
|
||||||
{eligibilityQuery.isLoading ? (
|
{eligibilityLoading ? (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted shadow-[var(--cp-shadow-1)]">
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted shadow-[var(--cp-shadow-1)]">
|
||||||
<span className="font-semibold text-foreground">Checking availability…</span>
|
<span className="font-semibold text-foreground">Checking availability…</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center max-w-md">
|
<p className="text-sm text-muted-foreground text-center max-w-md">
|
||||||
We’re verifying whether our service is available at your residence.
|
We're verifying whether our service is available at your residence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : autoRequestStatus === "submitting" ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted shadow-[var(--cp-shadow-1)]">
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
Submitting availability request
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center max-w-md">
|
||||||
|
We're sending your request now.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : autoRequestStatus === "submitted" ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-info/25 bg-info-soft text-info shadow-[var(--cp-shadow-1)]">
|
||||||
|
<span className="font-semibold">Availability review in progress</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground text-center max-w-md">
|
||||||
|
We've received your request and will notify you when the review is complete.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : isNotRequested ? (
|
) : isNotRequested ? (
|
||||||
@ -221,49 +307,81 @@ export function InternetPlansContainer() {
|
|||||||
) : null}
|
) : null}
|
||||||
</CatalogHero>
|
</CatalogHero>
|
||||||
|
|
||||||
{isNotRequested && (
|
{autoRequestStatus === "submitting" && (
|
||||||
<AlertBanner variant="info" title="Request an eligibility review" className="mb-8">
|
<AlertBanner variant="info" title="Submitting availability request" className="mb-8">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
We're sending your request now. You'll see updated eligibility once the review begins.
|
||||||
<p className="text-sm text-foreground/80">
|
|
||||||
Our team will verify NTT serviceability and update your eligible offerings. We’ll
|
|
||||||
notify you when review is complete.
|
|
||||||
</p>
|
|
||||||
{hasServiceAddress ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
disabled={eligibilityRequest.isPending}
|
|
||||||
isLoading={eligibilityRequest.isPending}
|
|
||||||
loadingText="Requesting…"
|
|
||||||
onClick={() =>
|
|
||||||
void (async () => {
|
|
||||||
const confirmed =
|
|
||||||
typeof window === "undefined" ||
|
|
||||||
window.confirm(
|
|
||||||
`Request an eligibility review for this address?\n\n${addressLabel}`
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
eligibilityRequest.mutate({ address: user?.address ?? undefined });
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
className="sm:ml-auto whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Request review now
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
href="/account/settings"
|
|
||||||
size="sm"
|
|
||||||
className="sm:ml-auto whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Add address to continue
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{autoRequestStatus === "submitted" && (
|
||||||
|
<AlertBanner variant="success" title="Request received" className="mb-8">
|
||||||
|
We've received your availability request. Our team will investigate and notify you when
|
||||||
|
the review is complete.
|
||||||
|
{autoRequestId && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-2">
|
||||||
|
Request ID: <span className="font-mono">{autoRequestId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertBanner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{autoRequestStatus === "failed" && (
|
||||||
|
<AlertBanner variant="warning" title="We couldn't submit your request" className="mb-8">
|
||||||
|
Please try again below or contact support if this keeps happening.
|
||||||
|
</AlertBanner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{autoRequestStatus === "missing_address" && (
|
||||||
|
<AlertBanner variant="warning" title="We need your address to continue" className="mb-8">
|
||||||
|
Add your service address so we can submit the availability request.
|
||||||
|
</AlertBanner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isNotRequested &&
|
||||||
|
autoRequestStatus !== "submitting" &&
|
||||||
|
autoRequestStatus !== "submitted" && (
|
||||||
|
<AlertBanner variant="info" title="Request an eligibility review" className="mb-8">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<p className="text-sm text-foreground/80">
|
||||||
|
Our team will verify NTT serviceability and update your eligible offerings. We’ll
|
||||||
|
notify you when review is complete.
|
||||||
|
</p>
|
||||||
|
{hasServiceAddress ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={eligibilityRequest.isPending}
|
||||||
|
isLoading={eligibilityRequest.isPending}
|
||||||
|
loadingText="Requesting…"
|
||||||
|
onClick={() =>
|
||||||
|
void (async () => {
|
||||||
|
const confirmed =
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
window.confirm(
|
||||||
|
`Request an eligibility review for this address?\n\n${addressLabel}`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
eligibilityRequest.mutate({ address: user?.address ?? undefined });
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
className="sm:ml-auto whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Request review now
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href="/account/settings"
|
||||||
|
size="sm"
|
||||||
|
className="sm:ml-auto whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Add address to continue
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AlertBanner>
|
||||||
|
)}
|
||||||
|
|
||||||
{isPending && (
|
{isPending && (
|
||||||
<AlertBanner variant="info" title="Review in progress" className="mb-8">
|
<AlertBanner variant="info" title="Review in progress" className="mb-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -347,6 +465,7 @@ export function InternetPlansContainer() {
|
|||||||
<InternetPlanCard
|
<InternetPlanCard
|
||||||
plan={plan}
|
plan={plan}
|
||||||
installations={installations}
|
installations={installations}
|
||||||
|
titlePriority="base"
|
||||||
disabled={hasActiveInternet || orderingLocked}
|
disabled={hasActiveInternet || orderingLocked}
|
||||||
disabledReason={
|
disabledReason={
|
||||||
hasActiveInternet
|
hasActiveInternet
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
GlobeAltIcon,
|
GlobeAltIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard";
|
import { ServiceHeroCard } from "@/features/catalog/components/common/ServiceHeroCard";
|
||||||
import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
|
import { FeatureCard } from "@/features/catalog/components/common/FeatureCard";
|
||||||
@ -39,6 +40,35 @@ export function PublicCatalogHomeView() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/40 border border-border rounded-2xl p-6 mb-10">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "Pick a service",
|
||||||
|
description: "Internet, SIM, or VPN based on your needs.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Create an account",
|
||||||
|
description: "Confirm your address and unlock eligibility checks.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Configure and order",
|
||||||
|
description: "Choose your plan and complete checkout.",
|
||||||
|
},
|
||||||
|
].map(step => (
|
||||||
|
<div key={step.title} className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<CheckCircleIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-foreground">{step.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{step.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-10">
|
||||||
<ServiceHeroCard
|
<ServiceHeroCard
|
||||||
title="Internet Service"
|
title="Internet Service"
|
||||||
|
|||||||
@ -1,17 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { ServerIcon, CheckIcon } from "@heroicons/react/24/outline";
|
import { CheckIcon, ServerIcon } from "@heroicons/react/24/outline";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
|
||||||
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
||||||
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
||||||
|
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||||
import { useInternetPlan } from "@/features/catalog/hooks";
|
|
||||||
import { AuthModal } from "@/features/auth/components/AuthModal/AuthModal";
|
|
||||||
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public Internet Configure View
|
* Public Internet Configure View
|
||||||
@ -23,170 +17,79 @@ export function PublicInternetConfigureView() {
|
|||||||
const shopBasePath = useShopBasePath();
|
const shopBasePath = useShopBasePath();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const planSku = searchParams?.get("planSku");
|
const planSku = searchParams?.get("planSku");
|
||||||
const { plan, isLoading } = useInternetPlan(planSku || undefined);
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
||||||
const [authMode, setAuthMode] = useState<"signup" | "login">("signup");
|
|
||||||
|
|
||||||
const redirectTo = planSku
|
const redirectTo = planSku
|
||||||
? `/account/shop/internet/configure?planSku=${encodeURIComponent(planSku)}`
|
? `/account/shop/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}`
|
||||||
: "/account/shop/internet";
|
: "/account/shop/internet?autoEligibilityRequest=1";
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
|
||||||
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
|
|
||||||
<div className="mt-8 space-y-6">
|
|
||||||
<Skeleton className="h-10 w-96" />
|
|
||||||
<Skeleton className="h-32 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!plan) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
|
||||||
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
|
|
||||||
<AlertBanner variant="error" title="Plan not found">
|
|
||||||
The selected plan could not be found. Please go back and select a plan.
|
|
||||||
</AlertBanner>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
|
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="Get started with your internet plan"
|
title="Check availability for your address"
|
||||||
description="Create an account to verify service availability for your address and configure your plan."
|
description="Create an account so we can verify service availability. We'll submit your request automatically."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Plan Summary Card */}
|
<div className="mt-8 space-y-8">
|
||||||
<div className="mt-8 bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
|
<div className="bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
|
||||||
<div className="flex items-start gap-4 mb-6">
|
<div className="flex items-start gap-4 mb-6">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
|
||||||
<ServerIcon className="h-6 w-6 text-primary" />
|
<ServerIcon className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||||
|
We'll check availability for your address
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This request unlocks the internet plans that match your building type and local
|
||||||
|
infrastructure.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">{plan.name}</h3>
|
|
||||||
{plan.description && (
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">{plan.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
{plan.internetPlanTier && (
|
|
||||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
|
|
||||||
{plan.internetPlanTier} Tier
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{plan.internetOfferingType && (
|
|
||||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border">
|
|
||||||
{plan.internetOfferingType}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<CardPricing
|
|
||||||
monthlyPrice={plan.monthlyPrice}
|
|
||||||
oneTimePrice={plan.oneTimePrice}
|
|
||||||
size="lg"
|
|
||||||
alignment="left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan Features */}
|
|
||||||
{plan.catalogMetadata?.features && plan.catalogMetadata.features.length > 0 && (
|
|
||||||
<div className="border-t border-border pt-6 mt-6">
|
<div className="border-t border-border pt-6 mt-6">
|
||||||
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
|
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
|
||||||
Plan Includes:
|
What happens next
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<ul className="space-y-3">
|
||||||
{plan.catalogMetadata.features.slice(0, 6).map((feature, index) => {
|
<li className="flex items-start gap-2">
|
||||||
const [label, detail] = feature.split(":");
|
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||||
return (
|
<span className="text-sm text-muted-foreground">
|
||||||
<li key={index} className="flex items-start gap-2">
|
Create your account and confirm your service address.
|
||||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
</li>
|
||||||
{label.trim()}
|
<li className="flex items-start gap-2">
|
||||||
{detail && (
|
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||||
<>
|
<span className="text-sm text-muted-foreground">
|
||||||
: <span className="text-foreground font-medium">{detail.trim()}</span>
|
We submit an availability request to our team.
|
||||||
</>
|
</span>
|
||||||
)}
|
</li>
|
||||||
</span>
|
<li className="flex items-start gap-2">
|
||||||
</li>
|
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||||
);
|
<span className="text-sm text-muted-foreground">
|
||||||
})}
|
You'll be notified when your personalized plans are ready.
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auth Prompt */}
|
|
||||||
<div className="mt-8 bg-muted/50 border border-border rounded-2xl p-6 md:p-8">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">Ready to get started?</h3>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">
|
|
||||||
Create an account to verify service availability for your address and complete your
|
|
||||||
order. We'll guide you through the setup process.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center max-w-md mx-auto">
|
<InlineAuthSection
|
||||||
<Button
|
title="Ready to get started?"
|
||||||
onClick={() => {
|
description="Create an account so we can verify service availability for your address. We'll submit your request automatically and notify you when it's reviewed."
|
||||||
setAuthMode("signup");
|
redirectTo={redirectTo}
|
||||||
setAuthModalOpen(true);
|
highlights={[
|
||||||
}}
|
{ title: "Verify Availability", description: "Check service at your address" },
|
||||||
className="flex-1"
|
{ title: "Personalized Plans", description: "See plans tailored to you" },
|
||||||
>
|
{ title: "Secure Ordering", description: "Complete your order safely" },
|
||||||
Create account
|
]}
|
||||||
</Button>
|
/>
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setAuthMode("login");
|
|
||||||
setAuthModalOpen(true);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-border">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground mb-1">Verify Availability</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Check service at your address</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground mb-1">Personalized Plans</div>
|
|
||||||
<div className="text-xs text-muted-foreground">See plans tailored to you</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground mb-1">Secure Ordering</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Complete your order safely</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AuthModal
|
|
||||||
isOpen={authModalOpen}
|
|
||||||
onClose={() => setAuthModalOpen(false)}
|
|
||||||
initialMode={authMode}
|
|
||||||
redirectTo={redirectTo}
|
|
||||||
title="Create your account"
|
|
||||||
description="We'll verify service availability for your address, then show personalized internet plans and configuration options."
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
|||||||
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
||||||
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||||
import { Button } from "@/components/atoms/button";
|
|
||||||
import { InternetImportantNotes } from "@/features/catalog/components/internet/InternetImportantNotes";
|
import { InternetImportantNotes } from "@/features/catalog/components/internet/InternetImportantNotes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -75,7 +74,7 @@ export function PublicInternetPlansView() {
|
|||||||
<Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />
|
<Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@ -107,14 +106,13 @@ export function PublicInternetPlansView() {
|
|||||||
<CatalogBackLink href={shopBasePath} label="Back to Services" />
|
<CatalogBackLink href={shopBasePath} label="Back to Services" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
title="Choose Your Internet Plan"
|
title="Choose Your Internet Type"
|
||||||
description="High-speed fiber internet with reliable connectivity for your home or business."
|
description="Compare apartment vs home options and pick the speed that fits your address."
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<p className="text-sm text-muted-foreground text-center max-w-xl">
|
<p className="text-sm text-muted-foreground text-center max-w-xl">
|
||||||
Prices shown are the <span className="font-medium text-foreground">Silver</span> tier so
|
Compare starting prices for each internet type. Create an account to check availability
|
||||||
you can compare starting prices. Create an account to check internet availability for
|
for your residence and unlock personalized plan options.
|
||||||
your residence and unlock personalized plan options.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{offeringTypes.map(type => (
|
{offeringTypes.map(type => (
|
||||||
@ -127,31 +125,27 @@ export function PublicInternetPlansView() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
|
||||||
<Button as="a" href="/auth/signup" className="whitespace-nowrap">
|
|
||||||
Get started
|
|
||||||
</Button>
|
|
||||||
<Button as="a" href="/auth/login" variant="outline" className="whitespace-nowrap">
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CatalogHero>
|
</CatalogHero>
|
||||||
|
|
||||||
{silverPlans.length > 0 ? (
|
{silverPlans.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
|
<div id="plans" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||||
{silverPlans.map(plan => (
|
{silverPlans.map(plan => (
|
||||||
<div key={plan.id}>
|
<div key={plan.id}>
|
||||||
<InternetPlanCard
|
<InternetPlanCard
|
||||||
plan={plan}
|
plan={plan}
|
||||||
installations={installations}
|
installations={installations}
|
||||||
|
titlePriority="detail"
|
||||||
disabled={false}
|
disabled={false}
|
||||||
pricingPrefix="Starting from"
|
pricingPrefix="Starting from"
|
||||||
action={{
|
action={{
|
||||||
label: "Get started",
|
label: "Get started",
|
||||||
href: `/shop/internet/configure?planSku=${encodeURIComponent(plan.sku)}`,
|
href: `/shop/internet/configure?planSku=${encodeURIComponent(plan.sku)}`,
|
||||||
}}
|
}}
|
||||||
|
showTierBadge={false}
|
||||||
|
showPlanSubtitle={false}
|
||||||
|
showFeatures={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { DevicePhoneMobileIcon, CheckIcon } from "@heroicons/react/24/outline";
|
import { DevicePhoneMobileIcon, CheckIcon } from "@heroicons/react/24/outline";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { Button } from "@/components/atoms/button";
|
|
||||||
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
||||||
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||||
import { useSimPlan } from "@/features/catalog/hooks";
|
import { useSimPlan } from "@/features/catalog/hooks";
|
||||||
import { AuthModal } from "@/features/auth/components/AuthModal/AuthModal";
|
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
||||||
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
@ -24,8 +22,6 @@ export function PublicSimConfigureView() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const planSku = searchParams?.get("planSku");
|
const planSku = searchParams?.get("planSku");
|
||||||
const { plan, isLoading } = useSimPlan(planSku || undefined);
|
const { plan, isLoading } = useSimPlan(planSku || undefined);
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
||||||
const [authMode, setAuthMode] = useState<"signup" | "login">("signup");
|
|
||||||
|
|
||||||
const redirectTarget = planSku
|
const redirectTarget = planSku
|
||||||
? `/account/shop/sim/configure?planSku=${encodeURIComponent(planSku)}`
|
? `/account/shop/sim/configure?planSku=${encodeURIComponent(planSku)}`
|
||||||
@ -56,7 +52,7 @@ export function PublicSimConfigureView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
|
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
|
||||||
|
|
||||||
<CatalogHero
|
<CatalogHero
|
||||||
@ -64,143 +60,99 @@ export function PublicSimConfigureView() {
|
|||||||
description="Create an account to complete your order, add a payment method, and complete identity verification."
|
description="Create an account to complete your order, add a payment method, and complete identity verification."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Plan Summary Card */}
|
<div className="mt-8 space-y-8">
|
||||||
<div className="mt-8 bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
|
{/* Plan Summary Card */}
|
||||||
<div className="flex items-start gap-4 mb-6">
|
<div className="bg-card border border-border rounded-2xl p-6 md:p-8 shadow-[var(--cp-shadow-1)]">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex items-start gap-4 mb-6">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
|
<div className="flex-shrink-0">
|
||||||
<DevicePhoneMobileIcon className="h-6 w-6 text-primary" />
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 border border-primary/20">
|
||||||
</div>
|
<DevicePhoneMobileIcon className="h-6 w-6 text-primary" />
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">{plan.name}</h3>
|
|
||||||
{plan.description && (
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">{plan.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
{plan.simPlanType && (
|
|
||||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
|
|
||||||
{plan.simPlanType}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<CardPricing
|
|
||||||
monthlyPrice={plan.monthlyPrice}
|
|
||||||
oneTimePrice={plan.oneTimePrice}
|
|
||||||
size="lg"
|
|
||||||
alignment="left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan Details */}
|
|
||||||
{(plan.simDataSize || plan.description) && (
|
|
||||||
<div className="border-t border-border pt-6 mt-6">
|
|
||||||
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
|
|
||||||
Plan Details:
|
|
||||||
</h4>
|
|
||||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{plan.simDataSize && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Data: <span className="text-foreground font-medium">{plan.simDataSize}</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{plan.simPlanType && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Type: <span className="text-foreground font-medium">{plan.simPlanType}</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{plan.simHasFamilyDiscount && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
<span className="text-foreground font-medium">Family Discount Available</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{plan.billingCycle && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Billing:{" "}
|
|
||||||
<span className="text-foreground font-medium">{plan.billingCycle}</span>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Auth Prompt */}
|
|
||||||
<div className="mt-8 bg-muted/50 border border-border rounded-2xl p-6 md:p-8">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">Ready to order?</h3>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">
|
|
||||||
Create an account to complete your SIM order. You'll need to add a payment method
|
|
||||||
and complete identity verification.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center max-w-md mx-auto">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setAuthMode("signup");
|
|
||||||
setAuthModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Create account
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setAuthMode("login");
|
|
||||||
setAuthModalOpen(true);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-border">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground mb-1">Secure Payment</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Add payment method safely</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground mb-1">
|
|
||||||
Identity Verification
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">Complete verification process</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-foreground mb-1">Order Management</div>
|
<h3 className="text-xl font-semibold text-foreground mb-2">{plan.name}</h3>
|
||||||
<div className="text-xs text-muted-foreground">Track your order status</div>
|
{plan.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">{plan.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{plan.simPlanType && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary border border-primary/20">
|
||||||
|
{plan.simPlanType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<CardPricing
|
||||||
|
monthlyPrice={plan.monthlyPrice}
|
||||||
|
oneTimePrice={plan.oneTimePrice}
|
||||||
|
size="lg"
|
||||||
|
alignment="left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Plan Details */}
|
||||||
|
{(plan.simDataSize || plan.description) && (
|
||||||
|
<div className="border-t border-border pt-6 mt-6">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
|
||||||
|
Plan Details:
|
||||||
|
</h4>
|
||||||
|
<ul className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{plan.simDataSize && (
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Data:{" "}
|
||||||
|
<span className="text-foreground font-medium">{plan.simDataSize}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{plan.simPlanType && (
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Type:{" "}
|
||||||
|
<span className="text-foreground font-medium">{plan.simPlanType}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{plan.simHasFamilyDiscount && (
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
Family Discount Available
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{plan.billingCycle && (
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<CheckIcon className="h-4 w-4 text-success mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Billing:{" "}
|
||||||
|
<span className="text-foreground font-medium">{plan.billingCycle}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InlineAuthSection
|
||||||
|
title="Ready to order?"
|
||||||
|
description="Create an account to complete your SIM order. You'll need to add a payment method and complete identity verification."
|
||||||
|
redirectTo={redirectTarget}
|
||||||
|
highlights={[
|
||||||
|
{ title: "Secure Payment", description: "Add payment method safely" },
|
||||||
|
{ title: "Identity Verification", description: "Complete verification process" },
|
||||||
|
{ title: "Order Management", description: "Track your order status" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AuthModal
|
|
||||||
isOpen={authModalOpen}
|
|
||||||
onClose={() => setAuthModalOpen(false)}
|
|
||||||
initialMode={authMode}
|
|
||||||
redirectTo={redirectTarget}
|
|
||||||
title="Create your account"
|
|
||||||
description="Ordering requires a payment method and identity verification."
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,13 +10,13 @@ import {
|
|||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
import { Button } from "@/components/atoms/button";
|
import { Button } from "@/components/atoms/button";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
|
||||||
import { useSimCatalog } from "@/features/catalog/hooks";
|
import { useSimCatalog } from "@/features/catalog/hooks";
|
||||||
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
|
||||||
import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection";
|
import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection";
|
||||||
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
||||||
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
||||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||||
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
|
|
||||||
interface PlansByType {
|
interface PlansByType {
|
||||||
DataOnly: SimCatalogProduct[];
|
DataOnly: SimCatalogProduct[];
|
||||||
@ -108,33 +108,6 @@ export function PublicSimPlansView() {
|
|||||||
description="Browse plan options now. Create an account to order, manage billing, and complete verification."
|
description="Browse plan options now. Create an account to order, manage billing, and complete verification."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertBanner
|
|
||||||
variant="info"
|
|
||||||
title="Account required to order"
|
|
||||||
className="mb-8 max-w-4xl mx-auto"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
||||||
<p className="text-sm text-foreground/80">
|
|
||||||
To place a SIM order you’ll need an account, a payment method, and identity
|
|
||||||
verification.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3 sm:ml-auto">
|
|
||||||
<Button as="a" href="/auth/signup" size="sm" className="whitespace-nowrap">
|
|
||||||
Get started
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
href={`/auth/login?redirect=${encodeURIComponent("/account/shop/sim")}`}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AlertBanner>
|
|
||||||
|
|
||||||
<div className="mb-8 flex justify-center">
|
<div className="mb-8 flex justify-center">
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
|
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
|
||||||
@ -208,7 +181,7 @@ export function PublicSimPlansView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-[500px] relative">
|
<div id="plans" className="min-h-[500px] relative">
|
||||||
{activeTab === "data-voice" && (
|
{activeTab === "data-voice" && (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
<SimPlanTypeSection
|
<SimPlanTypeSection
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, 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 { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
@ -24,8 +24,10 @@ import {
|
|||||||
useSubmitResidenceCard,
|
useSubmitResidenceCard,
|
||||||
} from "@/features/verification/hooks/useResidenceCardVerification";
|
} from "@/features/verification/hooks/useResidenceCardVerification";
|
||||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
|
||||||
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
|
import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders";
|
||||||
|
import { ssoLinkResponseSchema } from "@customer-portal/domain/auth";
|
||||||
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
import type { PaymentMethod } from "@customer-portal/domain/payments";
|
||||||
|
|
||||||
export function AccountCheckoutContainer() {
|
export function AccountCheckoutContainer() {
|
||||||
@ -39,6 +41,8 @@ export function AccountCheckoutContainer() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [openingPaymentPortal, setOpeningPaymentPortal] = useState(false);
|
||||||
|
const paymentToastTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const orderType: OrderTypeValue | null = useMemo(() => {
|
const orderType: OrderTypeValue | null = useMemo(() => {
|
||||||
if (!cartItem?.orderType) return null;
|
if (!cartItem?.orderType) return null;
|
||||||
@ -140,6 +144,30 @@ export function AccountCheckoutContainer() {
|
|||||||
const residenceSubmitted =
|
const residenceSubmitted =
|
||||||
!isSimOrder || residenceStatus === "pending" || residenceStatus === "verified";
|
!isSimOrder || residenceStatus === "pending" || residenceStatus === "verified";
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
paymentToastTimeoutRef.current = null;
|
||||||
|
}, 2200);
|
||||||
|
},
|
||||||
|
[paymentRefresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (paymentToastTimeoutRef.current) {
|
||||||
|
clearTimeout(paymentToastTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const formatDateTime = useCallback((iso?: string | null) => {
|
const formatDateTime = useCallback((iso?: string | null) => {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
@ -201,6 +229,27 @@ export function AccountCheckoutContainer() {
|
|||||||
}
|
}
|
||||||
}, [checkoutSessionId, clear, isSimOrder, pathname, router, searchParams]);
|
}, [checkoutSessionId, clear, isSimOrder, pathname, router, searchParams]);
|
||||||
|
|
||||||
|
const handleManagePayment = useCallback(async () => {
|
||||||
|
if (openingPaymentPortal) return;
|
||||||
|
setOpeningPaymentPortal(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.POST("/api/auth/sso-link", {
|
||||||
|
body: { destination: "index.php?rp=/account/paymentmethods" },
|
||||||
|
});
|
||||||
|
const data = ssoLinkResponseSchema.parse(response.data);
|
||||||
|
if (!data.url) {
|
||||||
|
throw new Error("No payment portal URL returned");
|
||||||
|
}
|
||||||
|
window.open(data.url, "_blank", "noopener,noreferrer");
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unable to open the payment portal";
|
||||||
|
showPaymentToast(message, "error");
|
||||||
|
} finally {
|
||||||
|
setOpeningPaymentPortal(false);
|
||||||
|
}
|
||||||
|
}, [openingPaymentPortal, showPaymentToast]);
|
||||||
|
|
||||||
if (!cartItem || !orderType) {
|
if (!cartItem || !orderType) {
|
||||||
const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop";
|
const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop";
|
||||||
return (
|
return (
|
||||||
@ -351,7 +400,14 @@ export function AccountCheckoutContainer() {
|
|||||||
right={
|
right={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasPaymentMethod ? <StatusPill label="Verified" variant="success" /> : undefined}
|
{hasPaymentMethod ? <StatusPill label="Verified" variant="success" /> : undefined}
|
||||||
<Button as="a" href="/account/billing/payments" size="sm" variant="outline">
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleManagePayment()}
|
||||||
|
isLoading={openingPaymentPortal}
|
||||||
|
loadingText="Opening..."
|
||||||
|
>
|
||||||
{hasPaymentMethod ? "Change" : "Add"}
|
{hasPaymentMethod ? "Change" : "Add"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -374,7 +430,13 @@ export function AccountCheckoutContainer() {
|
|||||||
>
|
>
|
||||||
Check Again
|
Check Again
|
||||||
</Button>
|
</Button>
|
||||||
<Button as="a" href="/account/billing/payments" size="sm">
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleManagePayment()}
|
||||||
|
isLoading={openingPaymentPortal}
|
||||||
|
loadingText="Opening..."
|
||||||
|
>
|
||||||
Add Payment Method
|
Add Payment Method
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -414,7 +476,13 @@ export function AccountCheckoutContainer() {
|
|||||||
>
|
>
|
||||||
Check Again
|
Check Again
|
||||||
</Button>
|
</Button>
|
||||||
<Button as="a" href="/account/billing/payments" size="sm">
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleManagePayment()}
|
||||||
|
isLoading={openingPaymentPortal}
|
||||||
|
loadingText="Opening..."
|
||||||
|
>
|
||||||
Add Payment Method
|
Add Payment Method
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
491
docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md
Normal file
491
docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
# Opportunity Lifecycle Management Guide
|
||||||
|
|
||||||
|
This guide documents the Salesforce Opportunity integration for service lifecycle tracking.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Existing Field Architecture](#existing-field-architecture)
|
||||||
|
3. [Opportunity Matching Rules](#opportunity-matching-rules)
|
||||||
|
4. [Internet Eligibility Flow](#internet-eligibility-flow)
|
||||||
|
5. [ID Verification Flow](#id-verification-flow)
|
||||||
|
6. [Order Placement Flow](#order-placement-flow)
|
||||||
|
7. [Service Activation Flow](#service-activation-flow)
|
||||||
|
8. [Cancellation Flow](#cancellation-flow)
|
||||||
|
9. [Implementation Checklist](#implementation-checklist)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
### What Customers See vs What's Internal
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ INTERNAL ONLY (Sales/CS Pipeline) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Opportunity Stages: Introduction, Ready (before Order placed) │
|
||||||
|
│ Application Stages: INTRO-1, UNRESPONSIVE-1, etc. (CS workflow) │
|
||||||
|
│ Eligibility fields on Account (eligibility status) │
|
||||||
|
│ ID Verification fields on Account (verification status) │
|
||||||
|
│ │
|
||||||
|
│ These are for internal tracking - customer sees results, not fields. │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CUSTOMER-FACING (Portal) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. ELIGIBILITY STATUS (from Account field) │
|
||||||
|
│ └─ "Checking...", "Eligible", "Not Eligible" │
|
||||||
|
│ │
|
||||||
|
│ 2. ID VERIFICATION STATUS (from Account field) │
|
||||||
|
│ └─ "Pending", "Verified", "Rejected" │
|
||||||
|
│ │
|
||||||
|
│ 3. ORDER TRACKING (from Salesforce Order) │
|
||||||
|
│ └─ After order placed, before WHMCS activates │
|
||||||
|
│ │
|
||||||
|
│ 4. SERVICE PAGE (from WHMCS) │
|
||||||
|
│ └─ After service is active │
|
||||||
|
│ │
|
||||||
|
│ 5. CANCELLATION STATUS (from Opportunity) │
|
||||||
|
│ └─ End date, return deadline, kit status │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Existing Field Architecture
|
||||||
|
|
||||||
|
### Account Fields (Already Exist)
|
||||||
|
|
||||||
|
**Internet Eligibility Fields:**
|
||||||
|
|
||||||
|
| Field | API Name | Purpose |
|
||||||
|
| ----------------- | ------------------------------------------- | ----------------------------- |
|
||||||
|
| Eligibility Value | `Internet_Eligibility__c` | The actual eligibility result |
|
||||||
|
| Status | `Internet_Eligibility_Status__c` | Pending, Checked |
|
||||||
|
| Requested At | `Internet_Eligibility_Request_Date_Time__c` | When request was made |
|
||||||
|
| Checked At | `Internet_Eligibility_Checked_Date_Time__c` | When eligibility was checked |
|
||||||
|
| Notes | `Internet_Eligibility_Notes__c` | Agent notes |
|
||||||
|
| Case ID | `Internet_Eligibility_Case_Id__c` | Linked Case for request |
|
||||||
|
|
||||||
|
**ID Verification Fields:**
|
||||||
|
|
||||||
|
| Field | API Name | Purpose |
|
||||||
|
| ----------------- | ---------------------------------------- | --------------------------- |
|
||||||
|
| Status | `Id_Verification_Status__c` | Pending, Verified, Rejected |
|
||||||
|
| Submitted At | `Id_Verification_Submitted_Date_Time__c` | When submitted |
|
||||||
|
| Verified At | `Id_Verification_Verified_Date_Time__c` | When verified |
|
||||||
|
| Notes | `Id_Verification_Note__c` | Agent notes |
|
||||||
|
| Rejection Message | `Id_Verification_Rejection_Message__c` | Reason for rejection |
|
||||||
|
|
||||||
|
### Opportunity Fields (Existing)
|
||||||
|
|
||||||
|
| Field | API Name | Purpose |
|
||||||
|
| ---------------------- | ------------------------------------- | --------------------------------------------------------------- |
|
||||||
|
| Stage | `StageName` | Introduction, Ready, Post Processing, Active, △Cancelling, etc. |
|
||||||
|
| Commodity Type | `CommodityType` | Personal SonixNet Home Internet, SIM, VPN |
|
||||||
|
| Application Stage | `Application_Stage__c` | INTRO-1 (for portal) |
|
||||||
|
| Cancellation Notice | `CancellationNotice__c` | 有, 未, 不要, 移転 |
|
||||||
|
| Scheduled Cancellation | `ScheduledCancellationDateAndTime__c` | End of cancellation month |
|
||||||
|
| Line Return Status | `LineReturn__c` | NotYet, SentKit, Returned, etc. |
|
||||||
|
|
||||||
|
### New Opportunity Fields (To Create)
|
||||||
|
|
||||||
|
| Field | API Name | Type | Purpose |
|
||||||
|
| ---------------- | --------------------- | -------- | -------------------------------- |
|
||||||
|
| Portal Source | `Portal_Source__c` | Picklist | How Opportunity was created |
|
||||||
|
| WHMCS Service ID | `WHMCS_Service_ID__c` | Number | Link to WHMCS after provisioning |
|
||||||
|
|
||||||
|
### Order Fields (Existing)
|
||||||
|
|
||||||
|
| Field | API Name | Purpose |
|
||||||
|
| ----------- | --------------- | -------------------------- |
|
||||||
|
| Opportunity | `OpportunityId` | Links Order TO Opportunity |
|
||||||
|
|
||||||
|
### Case Fields (Existing)
|
||||||
|
|
||||||
|
| Field | API Name | Purpose |
|
||||||
|
| ----------- | --------------- | ------------------------- |
|
||||||
|
| Opportunity | `OpportunityId` | Links Case TO Opportunity |
|
||||||
|
|
||||||
|
**Key Principle:** Cases and Orders link TO Opportunity, not vice versa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opportunity Matching Rules
|
||||||
|
|
||||||
|
### When to Find vs Create Opportunity
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ OPPORTUNITY MATCHING RULES │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ INTERNET ELIGIBILITY REQUEST: │
|
||||||
|
│ ───────────────────────────── │
|
||||||
|
│ 1. Check if Account already has eligibility status set │
|
||||||
|
│ └─ If already "Eligible" or "Not Eligible" → Don't create Opp │
|
||||||
|
│ │
|
||||||
|
│ 2. Find existing Opportunity: │
|
||||||
|
│ └─ WHERE AccountId = ? │
|
||||||
|
│ AND CommodityType IN ('Personal SonixNet Home Internet', │
|
||||||
|
│ 'Corporate SonixNet Home Internet') │
|
||||||
|
│ AND StageName = 'Introduction' │
|
||||||
|
│ AND IsClosed = false │
|
||||||
|
│ │
|
||||||
|
│ 3. If found → Use existing, create Case linked to it │
|
||||||
|
│ If not → Create new Opportunity, then create Case linked to it │
|
||||||
|
│ │
|
||||||
|
│ ORDER PLACEMENT: │
|
||||||
|
│ ───────────────── │
|
||||||
|
│ 1. Find existing Opportunity: │
|
||||||
|
│ └─ WHERE AccountId = ? │
|
||||||
|
│ AND CommodityType = ? (based on order type) │
|
||||||
|
│ AND StageName IN ('Introduction', 'Ready') │
|
||||||
|
│ AND IsClosed = false │
|
||||||
|
│ │
|
||||||
|
│ 2. If found → Use existing, update stage to 'Post Processing' │
|
||||||
|
│ If not → Create new Opportunity with stage 'Post Processing' │
|
||||||
|
│ │
|
||||||
|
│ 3. Create Order with Order.OpportunityId = Opportunity.Id │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stage Matching by Flow
|
||||||
|
|
||||||
|
| Flow | Match Stages | If Not Found |
|
||||||
|
| -------------------- | ------------------- | ------------------------------- |
|
||||||
|
| Internet Eligibility | Introduction only | Create with Introduction |
|
||||||
|
| Order Placement | Introduction, Ready | Create with Post Processing |
|
||||||
|
| Provisioning | Post Processing | Error (should exist from order) |
|
||||||
|
| Cancellation | Active | Error (service must be active) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internet Eligibility Flow
|
||||||
|
|
||||||
|
### Complete Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ INTERNET ELIGIBILITY FLOW │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. CUSTOMER ENTERS ADDRESS │
|
||||||
|
│ └─ Portal: POST /api/catalog/internet/eligibility-request │
|
||||||
|
│ │
|
||||||
|
│ 2. CHECK IF ELIGIBILITY ALREADY KNOWN │
|
||||||
|
│ └─ Query: SELECT Internet_Eligibility__c FROM Account │
|
||||||
|
│ └─ If already set → Return cached result, no action needed │
|
||||||
|
│ │
|
||||||
|
│ 3. FIND OR CREATE OPPORTUNITY │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Query: │ │
|
||||||
|
│ │ SELECT Id FROM Opportunity │ │
|
||||||
|
│ │ WHERE AccountId = ? │ │
|
||||||
|
│ │ AND CommodityType IN ('Personal SonixNet Home Internet', │ │
|
||||||
|
│ │ 'Corporate SonixNet Home Internet') │ │
|
||||||
|
│ │ AND StageName = 'Introduction' │ │
|
||||||
|
│ │ AND IsClosed = false │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ If found → Use existing │ │
|
||||||
|
│ │ If not → Create new: │ │
|
||||||
|
│ │ - Stage: Introduction │ │
|
||||||
|
│ │ - CommodityType: Personal SonixNet Home Internet │ │
|
||||||
|
│ │ - Portal_Source__c: Portal - Internet Eligibility Request │ │
|
||||||
|
│ │ - Application_Stage__c: INTRO-1 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 4. CREATE CASE (linked to Opportunity) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Case.Type = "Eligibility Check" │ │
|
||||||
|
│ │ Case.AccountId = customer's account │ │
|
||||||
|
│ │ Case.OpportunityId = opportunity from step 3 ←── THE LINK │ │
|
||||||
|
│ │ Case.Subject = "Internet Eligibility - {Address}" │ │
|
||||||
|
│ │ Case.Description = address details, postal code, etc. │ │
|
||||||
|
│ │ Case.Status = "New" │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 5. UPDATE ACCOUNT │
|
||||||
|
│ └─ Internet_Eligibility_Status__c = "Pending" │
|
||||||
|
│ └─ Internet_Eligibility_Request_Date_Time__c = now() │
|
||||||
|
│ └─ Internet_Eligibility_Case_Id__c = Case.Id │
|
||||||
|
│ │
|
||||||
|
│ 6. CS PROCESSES CASE │
|
||||||
|
│ └─ Checks with NTT / provider │
|
||||||
|
│ └─ Updates Account: │
|
||||||
|
│ - Internet_Eligibility__c = result │
|
||||||
|
│ - Internet_Eligibility_Status__c = "Checked" │
|
||||||
|
│ - Internet_Eligibility_Checked_Date_Time__c = now() │
|
||||||
|
│ └─ Updates Opportunity: │
|
||||||
|
│ - Eligible → Stage: Ready │
|
||||||
|
│ - Not Eligible → Stage: Void │
|
||||||
|
│ │
|
||||||
|
│ 7. PORTAL DETECTS CHANGE (via CDC or polling) │
|
||||||
|
│ └─ Shows eligibility result to customer │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### What Customer Sees
|
||||||
|
|
||||||
|
| Account Field State | Portal Shows |
|
||||||
|
| ---------------------------- | -------------------------------------- |
|
||||||
|
| Status = null | Address input form |
|
||||||
|
| Status = "Pending" | "Checking your address..." spinner |
|
||||||
|
| Eligibility = eligible value | Plan selection enabled |
|
||||||
|
| Eligibility = not eligible | "Sorry, service not available" message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ID Verification Flow
|
||||||
|
|
||||||
|
### Complete Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ID VERIFICATION (eKYC) FLOW │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ WHEN: During checkout registration or before order placement │
|
||||||
|
│ │
|
||||||
|
│ 1. CHECK IF ALREADY VERIFIED │
|
||||||
|
│ └─ Query: SELECT Id_Verification_Status__c FROM Account │
|
||||||
|
│ └─ If "Verified" → Skip verification, proceed to checkout │
|
||||||
|
│ │
|
||||||
|
│ 2. CUSTOMER UPLOADS ID DOCUMENTS │
|
||||||
|
│ └─ Portal: POST /api/verification/submit │
|
||||||
|
│ └─ eKYC service processes documents │
|
||||||
|
│ │
|
||||||
|
│ 3. UPDATE ACCOUNT │
|
||||||
|
│ └─ Id_Verification_Status__c = "Pending" │
|
||||||
|
│ └─ Id_Verification_Submitted_Date_Time__c = now() │
|
||||||
|
│ │
|
||||||
|
│ 4. IF eKYC AUTO-APPROVED │
|
||||||
|
│ └─ Id_Verification_Status__c = "Verified" │
|
||||||
|
│ └─ Id_Verification_Verified_Date_Time__c = now() │
|
||||||
|
│ └─ Customer can proceed to order immediately │
|
||||||
|
│ │
|
||||||
|
│ 5. IF MANUAL REVIEW NEEDED │
|
||||||
|
│ └─ Create Case for CS review │
|
||||||
|
│ └─ Case.Type = "ID Verification" │
|
||||||
|
│ └─ Case.OpportunityId = linked Opportunity (if exists) │
|
||||||
|
│ └─ CS reviews and updates Account │
|
||||||
|
│ │
|
||||||
|
│ 6. IF REJECTED │
|
||||||
|
│ └─ Id_Verification_Status__c = "Rejected" │
|
||||||
|
│ └─ Id_Verification_Rejection_Message__c = reason │
|
||||||
|
│ └─ Customer must resubmit │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### What Customer Sees
|
||||||
|
|
||||||
|
| Account Field State | Portal Shows |
|
||||||
|
| ------------------- | ------------------------------- |
|
||||||
|
| Status = null | ID upload form |
|
||||||
|
| Status = "Pending" | "Verifying your identity..." |
|
||||||
|
| Status = "Verified" | Proceed to checkout enabled |
|
||||||
|
| Status = "Rejected" | Error message + resubmit option |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Order Placement Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ORDER PLACEMENT FLOW │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ PREREQUISITES: │
|
||||||
|
│ - Internet: Eligibility = Eligible (Account field) │
|
||||||
|
│ - SIM: ID Verification = Verified (Account field) │
|
||||||
|
│ │
|
||||||
|
│ 1. CUSTOMER SUBMITS ORDER │
|
||||||
|
│ └─ Portal: POST /api/orders │
|
||||||
|
│ │
|
||||||
|
│ 2. FIND OR CREATE OPPORTUNITY │
|
||||||
|
│ └─ Query for existing (Introduction or Ready stage) │
|
||||||
|
│ └─ If found → Use existing │
|
||||||
|
│ └─ If not → Create with stage 'Post Processing' │
|
||||||
|
│ │
|
||||||
|
│ 3. CREATE SALESFORCE ORDER │
|
||||||
|
│ └─ Order.OpportunityId = Opportunity.Id ←── THE LINK │
|
||||||
|
│ └─ Order.Status = "Pending Review" │
|
||||||
|
│ │
|
||||||
|
│ 4. UPDATE OPPORTUNITY │
|
||||||
|
│ └─ Stage = "Post Processing" │
|
||||||
|
│ │
|
||||||
|
│ 5. CUSTOMER SEES ORDER TRACKING │
|
||||||
|
│ └─ "Your order is being processed" │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Activation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SERVICE ACTIVATION FLOW │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. AGENT APPROVES ORDER │
|
||||||
|
│ └─ Order.Status = "Approved" (triggers CDC) │
|
||||||
|
│ │
|
||||||
|
│ 2. BFF PROVISIONS TO WHMCS │
|
||||||
|
│ └─ Calls WHMCS AddOrder API │
|
||||||
|
│ └─ Passes OpportunityId as custom field ←── WHMCS GETS OPP ID │
|
||||||
|
│ └─ WHMCS returns serviceId │
|
||||||
|
│ │
|
||||||
|
│ 3. UPDATE OPPORTUNITY │
|
||||||
|
│ └─ WHMCS_Service_ID__c = serviceId ←── OPP GETS WHMCS ID │
|
||||||
|
│ └─ Stage = "Active" │
|
||||||
|
│ │
|
||||||
|
│ 4. BIDIRECTIONAL LINK COMPLETE │
|
||||||
|
│ └─ Opportunity.WHMCS_Service_ID__c → WHMCS Service │
|
||||||
|
│ └─ WHMCS Service.OpportunityId → Opportunity │
|
||||||
|
│ │
|
||||||
|
│ 5. CUSTOMER SEES SERVICE PAGE │
|
||||||
|
│ └─ Portal queries WHMCS → gets OpportunityId → queries Opp │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cancellation Flow
|
||||||
|
|
||||||
|
### Always Create Case
|
||||||
|
|
||||||
|
For **every** cancellation request, create a Case (notification to CS):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CANCELLATION FLOW │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. CUSTOMER SUBMITS CANCELLATION FORM │
|
||||||
|
│ └─ Selects month (25th rule applies) │
|
||||||
|
│ └─ Enters comments, alternative email (optional) │
|
||||||
|
│ │
|
||||||
|
│ 2. CREATE CANCELLATION CASE │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Case.Type = "Cancellation Request" │ │
|
||||||
|
│ │ Case.AccountId = customer's account │ │
|
||||||
|
│ │ Case.Subject = "Cancellation Request - {Product}" │ │
|
||||||
|
│ │ Case.Description = ALL form data: │ │
|
||||||
|
│ │ - WHMCS Service ID │ │
|
||||||
|
│ │ - Cancellation month │ │
|
||||||
|
│ │ - Alternative email (if provided) │ │
|
||||||
|
│ │ - Customer comments (if provided) │ │
|
||||||
|
│ │ Case.OpportunityId = linked Opportunity (if found) │ │
|
||||||
|
│ │ Case.Status = "New" │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 3. IF OPPORTUNITY IS LINKED (via WHMCS_Service_ID__c) │
|
||||||
|
│ └─ Update Opportunity: │
|
||||||
|
│ - Stage = "△Cancelling" │
|
||||||
|
│ - ScheduledCancellationDateAndTime__c = end of month │
|
||||||
|
│ - CancellationNotice__c = "有" │
|
||||||
|
│ - LineReturn__c = "NotYet" │
|
||||||
|
│ │
|
||||||
|
│ 4. IF NOT LINKED (Legacy) │
|
||||||
|
│ └─ Case contains all info for CS to process │
|
||||||
|
│ └─ CS will manually find and update correct Opportunity │
|
||||||
|
│ │
|
||||||
|
│ 5. CUSTOMER SEES │
|
||||||
|
│ └─ If linked: Cancellation status from Opportunity │
|
||||||
|
│ └─ If not linked: "Request received, we'll confirm by email" │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### The 25th Rule
|
||||||
|
|
||||||
|
```
|
||||||
|
TODAY'S DATE AVAILABLE CANCELLATION MONTHS
|
||||||
|
─────────────────────────────────────────────────────────
|
||||||
|
Before 25th of month → Can select THIS month or later
|
||||||
|
On/After 25th → Must select NEXT month or later
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cancellation Data Location
|
||||||
|
|
||||||
|
| Data | Where It Goes | Why |
|
||||||
|
| ----------------- | -------------------- | ------------------------- |
|
||||||
|
| Scheduled date | Opportunity | Lifecycle tracking |
|
||||||
|
| Notice status | Opportunity | Lifecycle tracking |
|
||||||
|
| Return status | Opportunity | Lifecycle tracking |
|
||||||
|
| Customer comments | **Case** | Not needed on Opp |
|
||||||
|
| Alternative email | **Case** | Not needed on Opp |
|
||||||
|
| WHMCS Service ID | Case (for reference) | Helps CS identify service |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Salesforce Admin Tasks
|
||||||
|
|
||||||
|
**Existing Fields (No Changes):**
|
||||||
|
|
||||||
|
- [x] Opportunity Stage picklist
|
||||||
|
- [x] CommodityType field
|
||||||
|
- [x] Application_Stage\_\_c
|
||||||
|
- [x] CancellationNotice\_\_c
|
||||||
|
- [x] LineReturn\_\_c
|
||||||
|
- [x] ScheduledCancellationDateAndTime\_\_c
|
||||||
|
- [x] Account Internet eligibility fields
|
||||||
|
- [x] Account ID verification fields
|
||||||
|
- [x] Case.OpportunityId (standard lookup)
|
||||||
|
- [x] Order.OpportunityId (standard lookup)
|
||||||
|
|
||||||
|
**New Fields to Create on Opportunity:**
|
||||||
|
|
||||||
|
- [ ] `Portal_Source__c` picklist
|
||||||
|
- [ ] `WHMCS_Service_ID__c` number field
|
||||||
|
|
||||||
|
### WHMCS Admin Tasks
|
||||||
|
|
||||||
|
- [ ] Create `OpportunityId` custom field on Services/Hosting
|
||||||
|
- [ ] Document custom field ID for AddOrder API
|
||||||
|
|
||||||
|
### BFF Development Tasks
|
||||||
|
|
||||||
|
**Completed:**
|
||||||
|
|
||||||
|
- [x] Domain types in `packages/domain/opportunity/`
|
||||||
|
- [x] Field map configuration
|
||||||
|
- [x] `SalesforceOpportunityService`
|
||||||
|
- [x] Cancellation deadline helpers (25th rule)
|
||||||
|
|
||||||
|
**Pending:**
|
||||||
|
|
||||||
|
- [ ] Register services in Salesforce module
|
||||||
|
- [ ] Integrate Opportunity matching into eligibility flow
|
||||||
|
- [ ] Integrate Opportunity matching into order placement
|
||||||
|
- [ ] Update order provisioning to pass OpportunityId to WHMCS
|
||||||
|
- [ ] Update WHMCS mapper to read OpportunityId custom field
|
||||||
|
- [ ] Create cancellation Case service
|
||||||
|
- [ ] Integrate Case creation into cancellation flow
|
||||||
|
|
||||||
|
### Frontend Tasks
|
||||||
|
|
||||||
|
- [ ] Order tracking page (from Salesforce Order)
|
||||||
|
- [ ] Service page with cancellation status
|
||||||
|
- [ ] Cancellation form with 25th deadline logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Salesforce-WHMCS Mapping](./SALESFORCE-WHMCS-MAPPING-REFERENCE.md)
|
||||||
|
- [Order Provisioning](../orders/PORTAL-ORDERING-PROVISIONING.md)
|
||||||
@ -56,6 +56,7 @@ export const ErrorCode = {
|
|||||||
ORDER_ALREADY_PROCESSED: "BIZ_004",
|
ORDER_ALREADY_PROCESSED: "BIZ_004",
|
||||||
INSUFFICIENT_BALANCE: "BIZ_005",
|
INSUFFICIENT_BALANCE: "BIZ_005",
|
||||||
SERVICE_UNAVAILABLE: "BIZ_006",
|
SERVICE_UNAVAILABLE: "BIZ_006",
|
||||||
|
LEGACY_ACCOUNT_EXISTS: "BIZ_007",
|
||||||
|
|
||||||
// System Errors (SYS_*)
|
// System Errors (SYS_*)
|
||||||
INTERNAL_ERROR: "SYS_001",
|
INTERNAL_ERROR: "SYS_001",
|
||||||
@ -104,8 +105,12 @@ export const ErrorMessages: Record<ErrorCodeType, string> = {
|
|||||||
[ErrorCode.NOT_FOUND]: "The requested resource was not found.",
|
[ErrorCode.NOT_FOUND]: "The requested resource was not found.",
|
||||||
|
|
||||||
// Business Logic
|
// Business Logic
|
||||||
[ErrorCode.ACCOUNT_EXISTS]: "An account with this email already exists. Please sign in.",
|
[ErrorCode.ACCOUNT_EXISTS]:
|
||||||
[ErrorCode.ACCOUNT_ALREADY_LINKED]: "This billing account is already linked. Please sign in.",
|
"We couldn't create a new account with these details. Please sign in or contact support.",
|
||||||
|
[ErrorCode.ACCOUNT_ALREADY_LINKED]:
|
||||||
|
"This billing account is already linked to a portal account. Please sign in.",
|
||||||
|
[ErrorCode.LEGACY_ACCOUNT_EXISTS]:
|
||||||
|
"We couldn't create a new account with these details. Please transfer your account or contact support.",
|
||||||
[ErrorCode.CUSTOMER_NOT_FOUND]: "Customer account not found. Please contact support.",
|
[ErrorCode.CUSTOMER_NOT_FOUND]: "Customer account not found. Please contact support.",
|
||||||
[ErrorCode.ORDER_ALREADY_PROCESSED]: "This order has already been processed.",
|
[ErrorCode.ORDER_ALREADY_PROCESSED]: "This order has already been processed.",
|
||||||
[ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.",
|
[ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.",
|
||||||
@ -259,6 +264,13 @@ export const ErrorMetadata: Record<ErrorCodeType, ErrorMetadata> = {
|
|||||||
shouldRetry: false,
|
shouldRetry: false,
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
},
|
},
|
||||||
|
[ErrorCode.LEGACY_ACCOUNT_EXISTS]: {
|
||||||
|
category: "business",
|
||||||
|
severity: "low",
|
||||||
|
shouldLogout: false,
|
||||||
|
shouldRetry: false,
|
||||||
|
logLevel: "info",
|
||||||
|
},
|
||||||
[ErrorCode.CUSTOMER_NOT_FOUND]: {
|
[ErrorCode.CUSTOMER_NOT_FOUND]: {
|
||||||
category: "business",
|
category: "business",
|
||||||
severity: "medium",
|
severity: "medium",
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export * as Customer from "./customer/index.js";
|
|||||||
export * as Dashboard from "./dashboard/index.js";
|
export * as Dashboard from "./dashboard/index.js";
|
||||||
export * as Auth from "./auth/index.js";
|
export * as Auth from "./auth/index.js";
|
||||||
export * as Mappings from "./mappings/index.js";
|
export * as Mappings from "./mappings/index.js";
|
||||||
|
export * as Opportunity from "./opportunity/index.js";
|
||||||
export * as Orders from "./orders/index.js";
|
export * as Orders from "./orders/index.js";
|
||||||
export * as Payments from "./payments/index.js";
|
export * as Payments from "./payments/index.js";
|
||||||
export * as Realtime from "./realtime/index.js";
|
export * as Realtime from "./realtime/index.js";
|
||||||
|
|||||||
739
packages/domain/opportunity/contract.ts
Normal file
739
packages/domain/opportunity/contract.ts
Normal file
@ -0,0 +1,739 @@
|
|||||||
|
/**
|
||||||
|
* Opportunity Domain - Contract
|
||||||
|
*
|
||||||
|
* Business types and constants for Salesforce Opportunity lifecycle management.
|
||||||
|
* Used to track customer journeys from interest through service cancellation.
|
||||||
|
*
|
||||||
|
* IMPORTANT: Stage values match existing Salesforce picklist values.
|
||||||
|
* See docs/salesforce/OPPORTUNITY-LIFECYCLE-GUIDE.md for complete documentation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Opportunity Stage Constants (Existing Salesforce Values)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opportunity lifecycle stages - these match existing Salesforce picklist values
|
||||||
|
*
|
||||||
|
* Portal Flow: Introduction -> Ready -> Post Processing -> Active -> △Cancelling -> 〇Cancelled
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_STAGE = {
|
||||||
|
// Interest/Lead stages
|
||||||
|
INTRODUCTION: "Introduction", // Initial interest / eligibility pending (30%)
|
||||||
|
WIKI: "WIKI", // Low priority / informational (10%)
|
||||||
|
|
||||||
|
// Ready to proceed
|
||||||
|
READY: "Ready", // Eligible / ready to order (60%)
|
||||||
|
|
||||||
|
// Order/Processing stages
|
||||||
|
POST_PROCESSING: "Post Processing", // Order placed, processing (75%)
|
||||||
|
|
||||||
|
// Active service
|
||||||
|
ACTIVE: "Active", // Service active (90%)
|
||||||
|
|
||||||
|
// Cancellation stages
|
||||||
|
CANCELLING: "△Cancelling", // Cancellation pending (100%, Open)
|
||||||
|
CANCELLED: "〇Cancelled", // Successfully cancelled (100%, Closed/Won)
|
||||||
|
|
||||||
|
// Completed/Closed stages
|
||||||
|
COMPLETED: "Completed", // Service completed normally (100%, Closed/Won)
|
||||||
|
VOID: "Void", // Lost / not eligible (0%, Closed/Lost)
|
||||||
|
PENDING: "Pending", // On hold / abandoned (0%, Closed/Lost)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OpportunityStageValue = (typeof OPPORTUNITY_STAGE)[keyof typeof OPPORTUNITY_STAGE];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Application Stage Constants (Existing Salesforce Values)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application stage picklist (Application_Stage__c)
|
||||||
|
* Tracks application process step
|
||||||
|
*
|
||||||
|
* For portal, we use INTRO_1 (single-step intro, not multi-email legacy flow)
|
||||||
|
*/
|
||||||
|
export const APPLICATION_STAGE = {
|
||||||
|
INTRO_1: "INTRO-1", // Portal default - simple introduction
|
||||||
|
NA: "N/A", // Not applicable
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ApplicationStageValue = (typeof APPLICATION_STAGE)[keyof typeof APPLICATION_STAGE];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cancellation Notice Constants (Existing Salesforce Values)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation notice status (CancellationNotice__c)
|
||||||
|
* Tracks whether cancellation form has been received
|
||||||
|
*/
|
||||||
|
export const CANCELLATION_NOTICE = {
|
||||||
|
RECEIVED: "有", // Form received
|
||||||
|
NOT_YET: "未", // Not yet received (default)
|
||||||
|
NOT_REQUIRED: "不要", // Not required
|
||||||
|
TRANSFER: "移転", // Transferring (moving)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CancellationNoticeValue =
|
||||||
|
(typeof CANCELLATION_NOTICE)[keyof typeof CANCELLATION_NOTICE];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Line Return Status Constants (Existing Salesforce Values)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rental equipment return status (LineReturn__c)
|
||||||
|
* Tracks equipment return process after cancellation
|
||||||
|
*/
|
||||||
|
export const LINE_RETURN_STATUS = {
|
||||||
|
NOT_YET: "NotYet", // Not yet initiated (default)
|
||||||
|
SENT_KIT: "SentKit", // Return kit sent to customer
|
||||||
|
PICKUP_SCHEDULED: "AS/Pickup予定", // Pickup scheduled
|
||||||
|
RETURNED_1: "Returned1", // Returned (variant 1)
|
||||||
|
RETURNED: "Returned2", // Returned
|
||||||
|
NTT_DISPATCH: "NTT派遣", // NTT dispatch
|
||||||
|
COMPENSATED: "Compensated", // Compensation fee charged
|
||||||
|
NA: "N/A", // Not applicable (no equipment)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LineReturnStatusValue = (typeof LINE_RETURN_STATUS)[keyof typeof LINE_RETURN_STATUS];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Commodity Type Constants (Existing Salesforce Field: CommodityType)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commodity types - uses existing Salesforce CommodityType picklist
|
||||||
|
* Maps to WHMCS product categories
|
||||||
|
*/
|
||||||
|
export const COMMODITY_TYPE = {
|
||||||
|
// Internet types
|
||||||
|
PERSONAL_HOME_INTERNET: "Personal SonixNet Home Internet",
|
||||||
|
CORPORATE_HOME_INTERNET: "Corporate SonixNet Home Internet",
|
||||||
|
// SIM
|
||||||
|
SIM: "SIM",
|
||||||
|
// VPN
|
||||||
|
VPN: "VPN",
|
||||||
|
// Other (not used by portal)
|
||||||
|
TECH_SUPPORT: "Onsite Support",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CommodityTypeValue = (typeof COMMODITY_TYPE)[keyof typeof COMMODITY_TYPE];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified product types for portal logic
|
||||||
|
* Maps multiple CommodityType values to simple categories
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_PRODUCT_TYPE = {
|
||||||
|
INTERNET: "Internet",
|
||||||
|
SIM: "SIM",
|
||||||
|
VPN: "VPN",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OpportunityProductTypeValue =
|
||||||
|
(typeof OPPORTUNITY_PRODUCT_TYPE)[keyof typeof OPPORTUNITY_PRODUCT_TYPE];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map CommodityType to simplified product type
|
||||||
|
*/
|
||||||
|
export function getCommodityTypeProductType(
|
||||||
|
commodityType: CommodityTypeValue
|
||||||
|
): OpportunityProductTypeValue | null {
|
||||||
|
switch (commodityType) {
|
||||||
|
case COMMODITY_TYPE.PERSONAL_HOME_INTERNET:
|
||||||
|
case COMMODITY_TYPE.CORPORATE_HOME_INTERNET:
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.INTERNET;
|
||||||
|
case COMMODITY_TYPE.SIM:
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.SIM;
|
||||||
|
case COMMODITY_TYPE.VPN:
|
||||||
|
return OPPORTUNITY_PRODUCT_TYPE.VPN;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default CommodityType for a product type
|
||||||
|
* Used when creating new Opportunities
|
||||||
|
*
|
||||||
|
* NOTE: Currently only Personal SonixNet Home Internet is used for Internet.
|
||||||
|
* Corporate can be added later when needed.
|
||||||
|
*/
|
||||||
|
export function getDefaultCommodityType(
|
||||||
|
productType: OpportunityProductTypeValue
|
||||||
|
): CommodityTypeValue {
|
||||||
|
switch (productType) {
|
||||||
|
case OPPORTUNITY_PRODUCT_TYPE.INTERNET:
|
||||||
|
// Always use Personal for now - Corporate support can be added later
|
||||||
|
return COMMODITY_TYPE.PERSONAL_HOME_INTERNET;
|
||||||
|
case OPPORTUNITY_PRODUCT_TYPE.SIM:
|
||||||
|
return COMMODITY_TYPE.SIM;
|
||||||
|
case OPPORTUNITY_PRODUCT_TYPE.VPN:
|
||||||
|
return COMMODITY_TYPE.VPN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default commodity types used when creating opportunities from portal
|
||||||
|
* These are the only values the portal will create - agents can use others
|
||||||
|
*/
|
||||||
|
export const PORTAL_DEFAULT_COMMODITY_TYPES = {
|
||||||
|
INTERNET: COMMODITY_TYPE.PERSONAL_HOME_INTERNET,
|
||||||
|
SIM: COMMODITY_TYPE.SIM,
|
||||||
|
VPN: COMMODITY_TYPE.VPN,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Opportunity Source Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sources from which Opportunities are created (Portal_Source__c)
|
||||||
|
*/
|
||||||
|
export const OPPORTUNITY_SOURCE = {
|
||||||
|
INTERNET_ELIGIBILITY: "Portal - Internet Eligibility Request",
|
||||||
|
SIM_CHECKOUT_REGISTRATION: "Portal - SIM Checkout Registration",
|
||||||
|
ORDER_PLACEMENT: "Portal - Order Placement",
|
||||||
|
AGENT_CREATED: "Agent Created",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OpportunitySourceValue = (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Opportunity Matching Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages considered "open" for matching purposes
|
||||||
|
* Opportunities in these stages can be linked to new orders
|
||||||
|
*/
|
||||||
|
export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
|
||||||
|
OPPORTUNITY_STAGE.INTRODUCTION,
|
||||||
|
OPPORTUNITY_STAGE.READY,
|
||||||
|
OPPORTUNITY_STAGE.POST_PROCESSING,
|
||||||
|
OPPORTUNITY_STAGE.ACTIVE,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages that indicate the Opportunity is closed
|
||||||
|
*/
|
||||||
|
export const CLOSED_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
|
||||||
|
OPPORTUNITY_STAGE.CANCELLED,
|
||||||
|
OPPORTUNITY_STAGE.COMPLETED,
|
||||||
|
OPPORTUNITY_STAGE.VOID,
|
||||||
|
OPPORTUNITY_STAGE.PENDING,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cancellation Deadline Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day of month by which cancellation form must be received
|
||||||
|
* If after this day, cancellation applies to next month
|
||||||
|
*/
|
||||||
|
export const CANCELLATION_DEADLINE_DAY = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day of following month by which rental equipment must be returned
|
||||||
|
*/
|
||||||
|
export const RENTAL_RETURN_DEADLINE_DAY = 10;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Business Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opportunity record as returned from Salesforce
|
||||||
|
*/
|
||||||
|
export interface OpportunityRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accountId: string;
|
||||||
|
stage: OpportunityStageValue;
|
||||||
|
closeDate: string;
|
||||||
|
/** CommodityType - existing Salesforce field for product categorization */
|
||||||
|
commodityType?: CommodityTypeValue;
|
||||||
|
/** Simplified product type - derived from commodityType */
|
||||||
|
productType?: OpportunityProductTypeValue;
|
||||||
|
source?: OpportunitySourceValue;
|
||||||
|
applicationStage?: ApplicationStageValue;
|
||||||
|
isClosed: boolean;
|
||||||
|
|
||||||
|
// Linked entities
|
||||||
|
// Note: Cases and Orders link TO Opportunity (not stored here)
|
||||||
|
// - Case.OpportunityId → for eligibility, ID verification, cancellation
|
||||||
|
// - Order.OpportunityId → for order tracking
|
||||||
|
whmcsServiceId?: number;
|
||||||
|
|
||||||
|
// Cancellation fields (updated by CS when processing cancellation Case)
|
||||||
|
cancellationNotice?: CancellationNoticeValue;
|
||||||
|
scheduledCancellationDate?: string;
|
||||||
|
lineReturnStatus?: LineReturnStatusValue;
|
||||||
|
// NOTE: alternativeContactEmail and cancellationComments are on the Cancellation Case
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
createdDate: string;
|
||||||
|
lastModifiedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to create a new Opportunity
|
||||||
|
*
|
||||||
|
* Note: Opportunity Name is auto-generated by Salesforce workflow,
|
||||||
|
* so we don't need to provide account name. The service will use
|
||||||
|
* a placeholder that gets overwritten by Salesforce.
|
||||||
|
*/
|
||||||
|
export interface CreateOpportunityRequest {
|
||||||
|
accountId: string;
|
||||||
|
productType: OpportunityProductTypeValue;
|
||||||
|
stage: OpportunityStageValue;
|
||||||
|
source: OpportunitySourceValue;
|
||||||
|
|
||||||
|
/** Application stage, defaults to INTRO-1 */
|
||||||
|
applicationStage?: ApplicationStageValue;
|
||||||
|
|
||||||
|
/** Expected close date, defaults to 30 days from now */
|
||||||
|
closeDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to update Opportunity stage
|
||||||
|
*/
|
||||||
|
export interface UpdateOpportunityStageRequest {
|
||||||
|
opportunityId: string;
|
||||||
|
stage: OpportunityStageValue;
|
||||||
|
|
||||||
|
/** Optional: reason for stage change (for audit) */
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation form data from customer
|
||||||
|
*/
|
||||||
|
export interface CancellationFormData {
|
||||||
|
/**
|
||||||
|
* Selected cancellation month (YYYY-MM format)
|
||||||
|
* Service ends at end of this month
|
||||||
|
*/
|
||||||
|
cancellationMonth: string;
|
||||||
|
|
||||||
|
/** Customer confirms they have read cancellation terms */
|
||||||
|
confirmTermsRead: boolean;
|
||||||
|
|
||||||
|
/** Customer confirms they understand month-end cancellation */
|
||||||
|
confirmMonthEndCancellation: boolean;
|
||||||
|
|
||||||
|
/** Optional alternative email for post-cancellation communication */
|
||||||
|
alternativeEmail?: string;
|
||||||
|
|
||||||
|
/** Optional customer comments/notes */
|
||||||
|
comments?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation data to populate on Opportunity (transformed from form)
|
||||||
|
* Only core lifecycle fields - details go on Cancellation Case
|
||||||
|
*/
|
||||||
|
export interface CancellationOpportunityData {
|
||||||
|
/** End of cancellation month (YYYY-MM-DD format) */
|
||||||
|
scheduledCancellationDate: string;
|
||||||
|
|
||||||
|
/** Cancellation notice status (always 有 from portal) */
|
||||||
|
cancellationNotice: CancellationNoticeValue;
|
||||||
|
|
||||||
|
/** Initial line return status (always NotYet from portal) */
|
||||||
|
lineReturnStatus: LineReturnStatusValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data to populate on Cancellation Case
|
||||||
|
* This contains all customer-provided details
|
||||||
|
*/
|
||||||
|
export interface CancellationCaseData {
|
||||||
|
/** Account ID */
|
||||||
|
accountId: string;
|
||||||
|
|
||||||
|
/** Linked Opportunity ID (if known) */
|
||||||
|
opportunityId?: string;
|
||||||
|
|
||||||
|
/** WHMCS Service ID for reference */
|
||||||
|
whmcsServiceId: number;
|
||||||
|
|
||||||
|
/** Product type (Internet, SIM, VPN) */
|
||||||
|
productType: OpportunityProductTypeValue;
|
||||||
|
|
||||||
|
/** Selected cancellation month (YYYY-MM) */
|
||||||
|
cancellationMonth: string;
|
||||||
|
|
||||||
|
/** End of cancellation month (YYYY-MM-DD) */
|
||||||
|
cancellationDate: string;
|
||||||
|
|
||||||
|
/** Optional alternative contact email */
|
||||||
|
alternativeEmail?: string;
|
||||||
|
|
||||||
|
/** Optional customer comments */
|
||||||
|
comments?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation eligibility check result
|
||||||
|
*/
|
||||||
|
export interface CancellationEligibility {
|
||||||
|
/** Whether cancellation can be requested now */
|
||||||
|
canCancel: boolean;
|
||||||
|
|
||||||
|
/** Earliest month available for cancellation (YYYY-MM) */
|
||||||
|
earliestCancellationMonth: string;
|
||||||
|
|
||||||
|
/** Available cancellation months (up to 12 months ahead) */
|
||||||
|
availableMonths: CancellationMonthOption[];
|
||||||
|
|
||||||
|
/** Deadline for requesting cancellation this month */
|
||||||
|
currentMonthDeadline: string | null;
|
||||||
|
|
||||||
|
/** If cannot cancel, the reason why */
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month option for cancellation selection
|
||||||
|
*/
|
||||||
|
export interface CancellationMonthOption {
|
||||||
|
/** Value in YYYY-MM format */
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
/** Display label (e.g., "January 2025") */
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/** End date of the month (service end date) */
|
||||||
|
serviceEndDate: string;
|
||||||
|
|
||||||
|
/** Rental return deadline (10th of following month) */
|
||||||
|
rentalReturnDeadline: string;
|
||||||
|
|
||||||
|
/** Whether this is the current month (may have deadline) */
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation status for display in portal
|
||||||
|
*/
|
||||||
|
export interface CancellationStatus {
|
||||||
|
/** Current stage */
|
||||||
|
stage: OpportunityStageValue;
|
||||||
|
|
||||||
|
/** Whether cancellation is pending */
|
||||||
|
isPending: boolean;
|
||||||
|
|
||||||
|
/** Whether cancellation is complete */
|
||||||
|
isComplete: boolean;
|
||||||
|
|
||||||
|
/** Scheduled end date */
|
||||||
|
scheduledEndDate?: string;
|
||||||
|
|
||||||
|
/** Rental return status */
|
||||||
|
rentalReturnStatus?: LineReturnStatusValue;
|
||||||
|
|
||||||
|
/** Rental return deadline */
|
||||||
|
rentalReturnDeadline?: string;
|
||||||
|
|
||||||
|
/** Whether rental equipment needs to be returned */
|
||||||
|
hasRentalEquipment: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of Opportunity matching/resolution
|
||||||
|
*/
|
||||||
|
export interface OpportunityMatchResult {
|
||||||
|
/** The Opportunity ID (existing or newly created) */
|
||||||
|
opportunityId: string;
|
||||||
|
|
||||||
|
/** Whether a new Opportunity was created */
|
||||||
|
wasCreated: boolean;
|
||||||
|
|
||||||
|
/** Previous stage if updated */
|
||||||
|
previousStage?: OpportunityStageValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup criteria for finding existing Opportunities
|
||||||
|
*/
|
||||||
|
export interface OpportunityLookupCriteria {
|
||||||
|
accountId: string;
|
||||||
|
productType: OpportunityProductTypeValue;
|
||||||
|
|
||||||
|
/** Only match Opportunities in these stages */
|
||||||
|
allowedStages?: OpportunityStageValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Customer-Facing Service Display Model
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing service phase
|
||||||
|
* Only shows what's relevant to customers (no internal sales stages)
|
||||||
|
*/
|
||||||
|
export type CustomerServicePhase =
|
||||||
|
| "order" // Order placed, awaiting activation (has SF Order, no WHMCS)
|
||||||
|
| "active" // Service is live (has WHMCS service)
|
||||||
|
| "cancelling" // Cancellation pending
|
||||||
|
| "cancelled"; // Service ended
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the WHMCS service is linked to an Opportunity
|
||||||
|
*/
|
||||||
|
export type ServiceLinkStatus =
|
||||||
|
| "linked" // Opportunity has WHMCS_Service_ID__c (full features)
|
||||||
|
| "unlinked"; // Legacy - WHMCS exists but Opp not linked
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order tracking info (shown after order placed, before WHMCS activates)
|
||||||
|
* Source: Salesforce Order
|
||||||
|
*/
|
||||||
|
export interface OrderTrackingInfo {
|
||||||
|
/** Salesforce Order ID */
|
||||||
|
orderId: string;
|
||||||
|
|
||||||
|
/** Order number for display */
|
||||||
|
orderNumber: string;
|
||||||
|
|
||||||
|
/** Product being ordered */
|
||||||
|
productName: string;
|
||||||
|
|
||||||
|
/** Order status */
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
/** When order was placed */
|
||||||
|
orderDate: string;
|
||||||
|
|
||||||
|
/** Expected activation date (estimated) */
|
||||||
|
expectedActivation?: string;
|
||||||
|
|
||||||
|
/** Progress steps */
|
||||||
|
steps: Array<{
|
||||||
|
label: string;
|
||||||
|
status: "completed" | "current" | "upcoming";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service details from WHMCS (shown when service is active)
|
||||||
|
* This is the primary data source for customer service page
|
||||||
|
*/
|
||||||
|
export interface WhmcsServiceDetails {
|
||||||
|
/** WHMCS Service ID */
|
||||||
|
whmcsServiceId: number;
|
||||||
|
|
||||||
|
/** Product/plan name */
|
||||||
|
productName: string;
|
||||||
|
|
||||||
|
/** WHMCS status (Active, Suspended, etc.) */
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
/** Service address (for Internet) */
|
||||||
|
serviceAddress?: string;
|
||||||
|
|
||||||
|
/** Monthly amount */
|
||||||
|
monthlyAmount?: number;
|
||||||
|
|
||||||
|
/** Currency code */
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
/** Next billing date */
|
||||||
|
nextDueDate?: string;
|
||||||
|
|
||||||
|
/** Registration date */
|
||||||
|
registrationDate?: string;
|
||||||
|
|
||||||
|
/** Linked Opportunity ID (from WHMCS custom field) */
|
||||||
|
opportunityId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation info for display (shown when cancelling)
|
||||||
|
*/
|
||||||
|
export interface CancellationDisplayInfo {
|
||||||
|
/** When the service will end (end of month) */
|
||||||
|
serviceEndDate: string;
|
||||||
|
|
||||||
|
/** Deadline for returning equipment */
|
||||||
|
equipmentReturnDeadline: string;
|
||||||
|
|
||||||
|
/** Current return kit status */
|
||||||
|
returnKitStatus: LineReturnStatusValue;
|
||||||
|
|
||||||
|
/** Human-readable return status */
|
||||||
|
returnKitStatusLabel: string;
|
||||||
|
|
||||||
|
/** Whether equipment needs to be returned */
|
||||||
|
hasEquipmentToReturn: boolean;
|
||||||
|
|
||||||
|
/** Days remaining until service ends */
|
||||||
|
daysUntilServiceEnd: number;
|
||||||
|
|
||||||
|
/** Days remaining until return deadline */
|
||||||
|
daysUntilReturnDeadline: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing service view for the portal
|
||||||
|
*
|
||||||
|
* Combines data from WHMCS (primary) and Opportunity (lifecycle/cancellation)
|
||||||
|
* Handles both linked and legacy (unlinked) services
|
||||||
|
*/
|
||||||
|
export interface CustomerServiceView {
|
||||||
|
// ---- Identification ----
|
||||||
|
|
||||||
|
/** Unique ID for this view (WHMCS Service ID as string) */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** WHMCS Service ID */
|
||||||
|
whmcsServiceId: number;
|
||||||
|
|
||||||
|
/** Linked Opportunity ID (null for legacy services) */
|
||||||
|
opportunityId?: string;
|
||||||
|
|
||||||
|
/** Product type (Internet, SIM, VPN) */
|
||||||
|
productType: OpportunityProductTypeValue;
|
||||||
|
|
||||||
|
// ---- Display State ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing phase
|
||||||
|
* - order: Waiting for activation
|
||||||
|
* - active: Service is live
|
||||||
|
* - cancelling: Cancellation pending
|
||||||
|
* - cancelled: Service ended
|
||||||
|
*/
|
||||||
|
phase: CustomerServicePhase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this service has full Opportunity linking
|
||||||
|
* - linked: Full features (can show cancellation status, etc.)
|
||||||
|
* - unlinked: Legacy service (limited features)
|
||||||
|
*/
|
||||||
|
linkStatus: ServiceLinkStatus;
|
||||||
|
|
||||||
|
// ---- Service Data (from WHMCS) ----
|
||||||
|
|
||||||
|
/** Product/plan name */
|
||||||
|
productName: string;
|
||||||
|
|
||||||
|
/** WHMCS status */
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
/** Monthly amount */
|
||||||
|
monthlyAmount?: number;
|
||||||
|
|
||||||
|
/** Currency */
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
/** Next billing date */
|
||||||
|
nextDueDate?: string;
|
||||||
|
|
||||||
|
// ---- Cancellation Data (from Opportunity, if linked) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation info (only when phase = 'cancelling' and linked)
|
||||||
|
*/
|
||||||
|
cancellation?: CancellationDisplayInfo;
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
|
||||||
|
/** Whether cancel button should be shown */
|
||||||
|
canCancel: boolean;
|
||||||
|
|
||||||
|
/** Whether user can view invoices */
|
||||||
|
canViewInvoices: boolean;
|
||||||
|
|
||||||
|
/** Message to show if features are limited (for legacy) */
|
||||||
|
limitedFeaturesMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Opportunity stage to customer-facing phase
|
||||||
|
*/
|
||||||
|
export function getCustomerPhaseFromStage(
|
||||||
|
stage: OpportunityStageValue | undefined,
|
||||||
|
whmcsStatus: string
|
||||||
|
): CustomerServicePhase {
|
||||||
|
// If no Opportunity or WHMCS is active, derive from WHMCS status
|
||||||
|
if (!stage) {
|
||||||
|
if (whmcsStatus === "Active") return "active";
|
||||||
|
if (whmcsStatus === "Cancelled" || whmcsStatus === "Terminated") return "cancelled";
|
||||||
|
return "active"; // Default for legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (stage) {
|
||||||
|
case OPPORTUNITY_STAGE.INTRODUCTION:
|
||||||
|
case OPPORTUNITY_STAGE.READY:
|
||||||
|
case OPPORTUNITY_STAGE.POST_PROCESSING:
|
||||||
|
return "order"; // Customer sees "order in progress"
|
||||||
|
|
||||||
|
case OPPORTUNITY_STAGE.ACTIVE:
|
||||||
|
return "active";
|
||||||
|
|
||||||
|
case OPPORTUNITY_STAGE.CANCELLING:
|
||||||
|
return "cancelling";
|
||||||
|
|
||||||
|
case OPPORTUNITY_STAGE.CANCELLED:
|
||||||
|
case OPPORTUNITY_STAGE.COMPLETED:
|
||||||
|
case OPPORTUNITY_STAGE.VOID:
|
||||||
|
case OPPORTUNITY_STAGE.PENDING:
|
||||||
|
return "cancelled";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get order tracking progress steps
|
||||||
|
* Used for customers who have placed an order but WHMCS is not yet active
|
||||||
|
*/
|
||||||
|
export function getOrderTrackingSteps(
|
||||||
|
currentStage: OpportunityStageValue
|
||||||
|
): OrderTrackingInfo["steps"] {
|
||||||
|
const stages = [
|
||||||
|
{ stage: OPPORTUNITY_STAGE.POST_PROCESSING, label: "Order Received" },
|
||||||
|
{ stage: OPPORTUNITY_STAGE.POST_PROCESSING, label: "Processing" },
|
||||||
|
{ stage: OPPORTUNITY_STAGE.ACTIVE, label: "Activating" },
|
||||||
|
{ stage: OPPORTUNITY_STAGE.ACTIVE, label: "Active" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Simplify to 4 steps for customer clarity
|
||||||
|
let currentStep = 0;
|
||||||
|
if (currentStage === OPPORTUNITY_STAGE.POST_PROCESSING) {
|
||||||
|
currentStep = 1; // Processing
|
||||||
|
} else if (currentStage === OPPORTUNITY_STAGE.ACTIVE) {
|
||||||
|
currentStep = 3; // Active
|
||||||
|
}
|
||||||
|
|
||||||
|
return stages.map((s, index) => ({
|
||||||
|
label: s.label,
|
||||||
|
status: index < currentStep ? "completed" : index === currentStep ? "current" : "upcoming",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a service can be cancelled
|
||||||
|
*/
|
||||||
|
export function canServiceBeCancelled(
|
||||||
|
whmcsStatus: string,
|
||||||
|
opportunityStage?: OpportunityStageValue
|
||||||
|
): boolean {
|
||||||
|
// Can't cancel if WHMCS is not active
|
||||||
|
if (whmcsStatus !== "Active") return false;
|
||||||
|
|
||||||
|
// If linked to Opportunity, check stage
|
||||||
|
if (opportunityStage) {
|
||||||
|
// Can only cancel from Active stage
|
||||||
|
return opportunityStage === OPPORTUNITY_STAGE.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy service - allow cancellation (will create Case for agent)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
382
packages/domain/opportunity/helpers.ts
Normal file
382
packages/domain/opportunity/helpers.ts
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* Opportunity Domain - Helpers
|
||||||
|
*
|
||||||
|
* Utility functions for cancellation date calculations and validation.
|
||||||
|
* Implements the "25th rule" and rental return deadline logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
CANCELLATION_DEADLINE_DAY,
|
||||||
|
RENTAL_RETURN_DEADLINE_DAY,
|
||||||
|
CANCELLATION_NOTICE,
|
||||||
|
LINE_RETURN_STATUS,
|
||||||
|
type CancellationFormData,
|
||||||
|
type CancellationOpportunityData,
|
||||||
|
type CancellationEligibility,
|
||||||
|
type CancellationMonthOption,
|
||||||
|
} from "./contract.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Date Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last day of a month
|
||||||
|
* @param year - Full year (e.g., 2025)
|
||||||
|
* @param month - Month (1-12)
|
||||||
|
* @returns Date string in YYYY-MM-DD format
|
||||||
|
*/
|
||||||
|
export function getLastDayOfMonth(year: number, month: number): string {
|
||||||
|
// Day 0 of next month = last day of current month
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
const monthStr = String(month).padStart(2, "0");
|
||||||
|
const dayStr = String(lastDay).padStart(2, "0");
|
||||||
|
return `${year}-${monthStr}-${dayStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rental return deadline (10th of following month)
|
||||||
|
* @param year - Year of cancellation month
|
||||||
|
* @param month - Month of cancellation (1-12)
|
||||||
|
* @returns Date string in YYYY-MM-DD format
|
||||||
|
*/
|
||||||
|
export function getRentalReturnDeadline(year: number, month: number): string {
|
||||||
|
// Move to next month
|
||||||
|
let nextYear = year;
|
||||||
|
let nextMonth = month + 1;
|
||||||
|
if (nextMonth > 12) {
|
||||||
|
nextMonth = 1;
|
||||||
|
nextYear += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthStr = String(nextMonth).padStart(2, "0");
|
||||||
|
const dayStr = String(RENTAL_RETURN_DEADLINE_DAY).padStart(2, "0");
|
||||||
|
return `${nextYear}-${monthStr}-${dayStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse YYYY-MM format to year and month
|
||||||
|
*/
|
||||||
|
export function parseYearMonth(value: string): { year: number; month: number } | null {
|
||||||
|
const match = value.match(/^(\d{4})-(0[1-9]|1[0-2])$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
year: parseInt(match[1], 10),
|
||||||
|
month: parseInt(match[2], 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date as YYYY-MM
|
||||||
|
*/
|
||||||
|
export function formatYearMonth(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${year}-${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a month for display (e.g., "January 2025")
|
||||||
|
*/
|
||||||
|
export function formatMonthLabel(year: number, month: number): string {
|
||||||
|
const date = new Date(year, month - 1, 1);
|
||||||
|
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for display (e.g., "January 31, 2025")
|
||||||
|
*/
|
||||||
|
export function formatDateLabel(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cancellation Deadline Logic
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current date is before the cancellation deadline for a given month
|
||||||
|
*
|
||||||
|
* Rule: Cancellation form must be received by the 25th of the cancellation month
|
||||||
|
*
|
||||||
|
* @param cancellationYear - Year of cancellation
|
||||||
|
* @param cancellationMonth - Month of cancellation (1-12)
|
||||||
|
* @param today - Current date (for testing, defaults to now)
|
||||||
|
* @returns true if can still cancel for this month
|
||||||
|
*/
|
||||||
|
export function isBeforeCancellationDeadline(
|
||||||
|
cancellationYear: number,
|
||||||
|
cancellationMonth: number,
|
||||||
|
today: Date = new Date()
|
||||||
|
): boolean {
|
||||||
|
const deadlineDate = new Date(
|
||||||
|
cancellationYear,
|
||||||
|
cancellationMonth - 1,
|
||||||
|
CANCELLATION_DEADLINE_DAY,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59
|
||||||
|
);
|
||||||
|
return today <= deadlineDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cancellation deadline date for a month
|
||||||
|
*
|
||||||
|
* @param year - Year
|
||||||
|
* @param month - Month (1-12)
|
||||||
|
* @returns Date string in YYYY-MM-DD format
|
||||||
|
*/
|
||||||
|
export function getCancellationDeadline(year: number, month: number): string {
|
||||||
|
const monthStr = String(month).padStart(2, "0");
|
||||||
|
const dayStr = String(CANCELLATION_DEADLINE_DAY).padStart(2, "0");
|
||||||
|
return `${year}-${monthStr}-${dayStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the earliest month that can be selected for cancellation
|
||||||
|
*
|
||||||
|
* If today is on or after the 25th, earliest is next month.
|
||||||
|
* Otherwise, earliest is current month.
|
||||||
|
*
|
||||||
|
* @param today - Current date (for testing, defaults to now)
|
||||||
|
* @returns YYYY-MM format string
|
||||||
|
*/
|
||||||
|
export function getEarliestCancellationMonth(today: Date = new Date()): string {
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = today.getMonth() + 1; // 1-indexed
|
||||||
|
const day = today.getDate();
|
||||||
|
|
||||||
|
if (day > CANCELLATION_DEADLINE_DAY) {
|
||||||
|
// After 25th - earliest is next month
|
||||||
|
let nextMonth = month + 1;
|
||||||
|
let nextYear = year;
|
||||||
|
if (nextMonth > 12) {
|
||||||
|
nextMonth = 1;
|
||||||
|
nextYear += 1;
|
||||||
|
}
|
||||||
|
return `${nextYear}-${String(nextMonth).padStart(2, "0")}`;
|
||||||
|
} else {
|
||||||
|
// On or before 25th - can cancel this month
|
||||||
|
return `${year}-${String(month).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate available cancellation months
|
||||||
|
*
|
||||||
|
* Returns 12 months starting from the earliest available month.
|
||||||
|
*
|
||||||
|
* @param today - Current date (for testing)
|
||||||
|
* @returns Array of month options
|
||||||
|
*/
|
||||||
|
export function generateCancellationMonthOptions(
|
||||||
|
today: Date = new Date()
|
||||||
|
): CancellationMonthOption[] {
|
||||||
|
const earliestMonth = getEarliestCancellationMonth(today);
|
||||||
|
const parsed = parseYearMonth(earliestMonth);
|
||||||
|
if (!parsed) return [];
|
||||||
|
|
||||||
|
let { year, month } = parsed;
|
||||||
|
const currentYearMonth = formatYearMonth(today);
|
||||||
|
const options: CancellationMonthOption[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const value = `${year}-${String(month).padStart(2, "0")}`;
|
||||||
|
const serviceEndDate = getLastDayOfMonth(year, month);
|
||||||
|
const rentalReturnDeadline = getRentalReturnDeadline(year, month);
|
||||||
|
const isCurrentMonth = value === currentYearMonth;
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
value,
|
||||||
|
label: formatMonthLabel(year, month),
|
||||||
|
serviceEndDate,
|
||||||
|
rentalReturnDeadline,
|
||||||
|
isCurrentMonth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move to next month
|
||||||
|
month += 1;
|
||||||
|
if (month > 12) {
|
||||||
|
month = 1;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full cancellation eligibility information
|
||||||
|
*
|
||||||
|
* @param today - Current date (for testing)
|
||||||
|
* @returns Cancellation eligibility details
|
||||||
|
*/
|
||||||
|
export function getCancellationEligibility(today: Date = new Date()): CancellationEligibility {
|
||||||
|
const availableMonths = generateCancellationMonthOptions(today);
|
||||||
|
const earliestMonth = getEarliestCancellationMonth(today);
|
||||||
|
const currentYearMonth = formatYearMonth(today);
|
||||||
|
const day = today.getDate();
|
||||||
|
|
||||||
|
// Check if current month is still available
|
||||||
|
const canCancelThisMonth = day <= CANCELLATION_DEADLINE_DAY;
|
||||||
|
const currentMonthDeadline = canCancelThisMonth
|
||||||
|
? getCancellationDeadline(today.getFullYear(), today.getMonth() + 1)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
canCancel: true,
|
||||||
|
earliestCancellationMonth: earliestMonth,
|
||||||
|
availableMonths,
|
||||||
|
currentMonthDeadline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a selected cancellation month is allowed
|
||||||
|
*
|
||||||
|
* @param selectedMonth - YYYY-MM format
|
||||||
|
* @param today - Current date (for testing)
|
||||||
|
* @returns Validation result
|
||||||
|
*/
|
||||||
|
export function validateCancellationMonth(
|
||||||
|
selectedMonth: string,
|
||||||
|
today: Date = new Date()
|
||||||
|
): { valid: boolean; error?: string } {
|
||||||
|
const parsed = parseYearMonth(selectedMonth);
|
||||||
|
if (!parsed) {
|
||||||
|
return { valid: false, error: "Invalid month format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const earliestMonth = getEarliestCancellationMonth(today);
|
||||||
|
const earliestParsed = parseYearMonth(earliestMonth);
|
||||||
|
if (!earliestParsed) {
|
||||||
|
return { valid: false, error: "Unable to determine earliest month" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare dates
|
||||||
|
const selectedDate = new Date(parsed.year, parsed.month - 1, 1);
|
||||||
|
const earliestDate = new Date(earliestParsed.year, earliestParsed.month - 1, 1);
|
||||||
|
|
||||||
|
if (selectedDate < earliestDate) {
|
||||||
|
const deadline = CANCELLATION_DEADLINE_DAY;
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Cancellation requests for this month must be submitted by the ${deadline}th. The earliest available month is ${formatMonthLabel(earliestParsed.year, earliestParsed.month)}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Data Transformation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform cancellation form data to Opportunity update data
|
||||||
|
*
|
||||||
|
* @param formData - Customer form submission
|
||||||
|
* @returns Data to update on Opportunity
|
||||||
|
*/
|
||||||
|
export function transformCancellationFormToOpportunityData(
|
||||||
|
formData: CancellationFormData
|
||||||
|
): CancellationOpportunityData {
|
||||||
|
const parsed = parseYearMonth(formData.cancellationMonth);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error("Invalid cancellation month format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledCancellationDate = getLastDayOfMonth(parsed.year, parsed.month);
|
||||||
|
|
||||||
|
// NOTE: alternativeEmail and comments go to the Cancellation Case, not to Opportunity
|
||||||
|
return {
|
||||||
|
scheduledCancellationDate,
|
||||||
|
cancellationNotice: CANCELLATION_NOTICE.RECEIVED,
|
||||||
|
lineReturnStatus: LINE_RETURN_STATUS.NOT_YET,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate rental return deadline from scheduled cancellation date
|
||||||
|
*
|
||||||
|
* @param scheduledCancellationDate - End of cancellation month (YYYY-MM-DD)
|
||||||
|
* @returns Rental return deadline (10th of following month)
|
||||||
|
*/
|
||||||
|
export function calculateRentalReturnDeadline(scheduledCancellationDate: string): string {
|
||||||
|
const date = new Date(scheduledCancellationDate);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1; // 1-indexed
|
||||||
|
|
||||||
|
return getRentalReturnDeadline(year, month);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Display Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable status for line return
|
||||||
|
*/
|
||||||
|
export function getLineReturnStatusLabel(status: string | undefined): {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
} {
|
||||||
|
switch (status) {
|
||||||
|
case LINE_RETURN_STATUS.NOT_YET:
|
||||||
|
return {
|
||||||
|
label: "Return Pending",
|
||||||
|
description: "A return kit will be sent to you",
|
||||||
|
};
|
||||||
|
case LINE_RETURN_STATUS.SENT_KIT:
|
||||||
|
return {
|
||||||
|
label: "Return Kit Sent",
|
||||||
|
description: "Please return equipment using the provided kit",
|
||||||
|
};
|
||||||
|
case LINE_RETURN_STATUS.PICKUP_SCHEDULED:
|
||||||
|
return {
|
||||||
|
label: "Pickup Scheduled",
|
||||||
|
description: "Equipment pickup has been scheduled",
|
||||||
|
};
|
||||||
|
case LINE_RETURN_STATUS.RETURNED:
|
||||||
|
case "Returned1":
|
||||||
|
return {
|
||||||
|
label: "Returned",
|
||||||
|
description: "Equipment has been returned successfully",
|
||||||
|
};
|
||||||
|
case LINE_RETURN_STATUS.NTT_DISPATCH:
|
||||||
|
return {
|
||||||
|
label: "NTT Dispatch",
|
||||||
|
description: "NTT will handle equipment return",
|
||||||
|
};
|
||||||
|
case LINE_RETURN_STATUS.COMPENSATED:
|
||||||
|
return {
|
||||||
|
label: "Compensated",
|
||||||
|
description: "Compensation fee has been charged for unreturned equipment",
|
||||||
|
};
|
||||||
|
case LINE_RETURN_STATUS.NA:
|
||||||
|
return {
|
||||||
|
label: "Not Applicable",
|
||||||
|
description: "No rental equipment to return",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: "Unknown",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if rental equipment is applicable for a product type
|
||||||
|
*/
|
||||||
|
export function hasRentalEquipment(productType: string): boolean {
|
||||||
|
// Internet typically has rental equipment (router, modem)
|
||||||
|
// SIM and VPN do not
|
||||||
|
return productType === "Internet";
|
||||||
|
}
|
||||||
140
packages/domain/opportunity/index.ts
Normal file
140
packages/domain/opportunity/index.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Opportunity Domain
|
||||||
|
*
|
||||||
|
* Exports all Opportunity-related contracts, schemas, helpers, and types.
|
||||||
|
* Used for Salesforce Opportunity lifecycle management including cancellation.
|
||||||
|
*
|
||||||
|
* Key features:
|
||||||
|
* - Service lifecycle tracking (Introduction -> Active -> Cancelling -> Cancelled)
|
||||||
|
* - Cancellation deadline logic (25th of month rule)
|
||||||
|
* - Rental equipment return tracking
|
||||||
|
*
|
||||||
|
* Types are derived from Zod schemas (Schema-First Approach)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Stage constants (existing Salesforce picklist values)
|
||||||
|
OPPORTUNITY_STAGE,
|
||||||
|
type OpportunityStageValue,
|
||||||
|
// Application stage constants
|
||||||
|
APPLICATION_STAGE,
|
||||||
|
type ApplicationStageValue,
|
||||||
|
// Cancellation notice constants
|
||||||
|
CANCELLATION_NOTICE,
|
||||||
|
type CancellationNoticeValue,
|
||||||
|
// Line return status constants
|
||||||
|
LINE_RETURN_STATUS,
|
||||||
|
type LineReturnStatusValue,
|
||||||
|
// Commodity type constants (existing Salesforce CommodityType field)
|
||||||
|
COMMODITY_TYPE,
|
||||||
|
type CommodityTypeValue,
|
||||||
|
// Product type constants (simplified, derived from CommodityType)
|
||||||
|
OPPORTUNITY_PRODUCT_TYPE,
|
||||||
|
type OpportunityProductTypeValue,
|
||||||
|
// Default commodity types for portal
|
||||||
|
PORTAL_DEFAULT_COMMODITY_TYPES,
|
||||||
|
// Commodity type helpers
|
||||||
|
getCommodityTypeProductType,
|
||||||
|
getDefaultCommodityType,
|
||||||
|
// Source constants
|
||||||
|
OPPORTUNITY_SOURCE,
|
||||||
|
type OpportunitySourceValue,
|
||||||
|
// Matching constants
|
||||||
|
OPEN_OPPORTUNITY_STAGES,
|
||||||
|
CLOSED_OPPORTUNITY_STAGES,
|
||||||
|
// Deadline constants
|
||||||
|
CANCELLATION_DEADLINE_DAY,
|
||||||
|
RENTAL_RETURN_DEADLINE_DAY,
|
||||||
|
// Customer-facing service display types
|
||||||
|
type CustomerServicePhase,
|
||||||
|
type ServiceLinkStatus,
|
||||||
|
type OrderTrackingInfo,
|
||||||
|
type WhmcsServiceDetails,
|
||||||
|
type CancellationDisplayInfo,
|
||||||
|
type CustomerServiceView,
|
||||||
|
// Customer-facing helpers
|
||||||
|
getCustomerPhaseFromStage,
|
||||||
|
getOrderTrackingSteps,
|
||||||
|
canServiceBeCancelled,
|
||||||
|
} from "./contract.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contract Types (business types, not validated)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type {
|
||||||
|
OpportunityRecord as OpportunityRecordContract,
|
||||||
|
CreateOpportunityRequest as CreateOpportunityRequestContract,
|
||||||
|
UpdateOpportunityStageRequest as UpdateOpportunityStageRequestContract,
|
||||||
|
CancellationFormData as CancellationFormDataContract,
|
||||||
|
CancellationOpportunityData as CancellationOpportunityDataContract,
|
||||||
|
CancellationCaseData as CancellationCaseDataContract,
|
||||||
|
CancellationEligibility as CancellationEligibilityContract,
|
||||||
|
CancellationMonthOption as CancellationMonthOptionContract,
|
||||||
|
CancellationStatus as CancellationStatusContract,
|
||||||
|
OpportunityMatchResult as OpportunityMatchResultContract,
|
||||||
|
OpportunityLookupCriteria as OpportunityLookupCriteriaContract,
|
||||||
|
} from "./contract.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schemas and Validated Types (preferred)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Schemas
|
||||||
|
opportunityRecordSchema,
|
||||||
|
createOpportunityRequestSchema,
|
||||||
|
updateOpportunityStageRequestSchema,
|
||||||
|
cancellationFormDataSchema,
|
||||||
|
cancellationOpportunityDataSchema,
|
||||||
|
cancellationMonthOptionSchema,
|
||||||
|
cancellationEligibilitySchema,
|
||||||
|
cancellationStatusSchema,
|
||||||
|
opportunityLookupCriteriaSchema,
|
||||||
|
opportunityMatchResultSchema,
|
||||||
|
opportunityResponseSchema,
|
||||||
|
createOpportunityResponseSchema,
|
||||||
|
cancellationPreviewResponseSchema,
|
||||||
|
// Derived types (preferred - validated)
|
||||||
|
type OpportunityRecord,
|
||||||
|
type CreateOpportunityRequest,
|
||||||
|
type UpdateOpportunityStageRequest,
|
||||||
|
type CancellationFormData,
|
||||||
|
type CancellationOpportunityData,
|
||||||
|
type CancellationMonthOption,
|
||||||
|
type CancellationEligibility,
|
||||||
|
type CancellationStatus,
|
||||||
|
type OpportunityLookupCriteria,
|
||||||
|
type OpportunityMatchResult,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Date utilities
|
||||||
|
getLastDayOfMonth,
|
||||||
|
getRentalReturnDeadline,
|
||||||
|
parseYearMonth,
|
||||||
|
formatYearMonth,
|
||||||
|
formatMonthLabel,
|
||||||
|
formatDateLabel,
|
||||||
|
// Cancellation deadline logic
|
||||||
|
isBeforeCancellationDeadline,
|
||||||
|
getCancellationDeadline,
|
||||||
|
getEarliestCancellationMonth,
|
||||||
|
generateCancellationMonthOptions,
|
||||||
|
getCancellationEligibility,
|
||||||
|
validateCancellationMonth,
|
||||||
|
// Data transformation
|
||||||
|
transformCancellationFormToOpportunityData,
|
||||||
|
calculateRentalReturnDeadline,
|
||||||
|
// Display helpers
|
||||||
|
getLineReturnStatusLabel,
|
||||||
|
hasRentalEquipment,
|
||||||
|
} from "./helpers.js";
|
||||||
306
packages/domain/opportunity/schema.ts
Normal file
306
packages/domain/opportunity/schema.ts
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* Opportunity Domain - Schemas
|
||||||
|
*
|
||||||
|
* Zod schemas for runtime validation of Opportunity data.
|
||||||
|
* Includes cancellation form validation with deadline logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enum Value Arrays (for Zod schemas)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opportunity stage values - match existing Salesforce picklist
|
||||||
|
*/
|
||||||
|
const OPPORTUNITY_STAGE_VALUES = [
|
||||||
|
"Introduction",
|
||||||
|
"WIKI",
|
||||||
|
"Ready",
|
||||||
|
"Post Processing",
|
||||||
|
"Active",
|
||||||
|
"△Cancelling",
|
||||||
|
"〇Cancelled",
|
||||||
|
"Completed",
|
||||||
|
"Void",
|
||||||
|
"Pending",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application stage values
|
||||||
|
*/
|
||||||
|
const APPLICATION_STAGE_VALUES = ["INTRO-1", "N/A"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellation notice values
|
||||||
|
*/
|
||||||
|
const CANCELLATION_NOTICE_VALUES = ["有", "未", "不要", "移転"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line return status values
|
||||||
|
*/
|
||||||
|
const LINE_RETURN_STATUS_VALUES = [
|
||||||
|
"NotYet",
|
||||||
|
"SentKit",
|
||||||
|
"AS/Pickup予定",
|
||||||
|
"Returned1",
|
||||||
|
"Returned2",
|
||||||
|
"NTT派遣",
|
||||||
|
"Compensated",
|
||||||
|
"N/A",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const OPPORTUNITY_PRODUCT_TYPE_VALUES = ["Internet", "SIM", "VPN"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commodity type values - existing Salesforce picklist
|
||||||
|
*/
|
||||||
|
const COMMODITY_TYPE_VALUES = [
|
||||||
|
"Personal SonixNet Home Internet",
|
||||||
|
"Corporate SonixNet Home Internet",
|
||||||
|
"SIM",
|
||||||
|
"VPN",
|
||||||
|
"Onsite Support",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const OPPORTUNITY_SOURCE_VALUES = [
|
||||||
|
"Portal - Internet Eligibility Request",
|
||||||
|
"Portal - SIM Checkout Registration",
|
||||||
|
"Portal - Order Placement",
|
||||||
|
"Agent Created",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Opportunity Record Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for Opportunity record returned from Salesforce
|
||||||
|
*/
|
||||||
|
export const opportunityRecordSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
accountId: z.string(),
|
||||||
|
stage: z.enum(OPPORTUNITY_STAGE_VALUES),
|
||||||
|
closeDate: z.string(),
|
||||||
|
commodityType: z.enum(COMMODITY_TYPE_VALUES).optional(),
|
||||||
|
productType: z.enum(OPPORTUNITY_PRODUCT_TYPE_VALUES).optional(), // Derived from commodityType
|
||||||
|
source: z.enum(OPPORTUNITY_SOURCE_VALUES).optional(),
|
||||||
|
applicationStage: z.enum(APPLICATION_STAGE_VALUES).optional(),
|
||||||
|
isClosed: z.boolean(),
|
||||||
|
|
||||||
|
// Linked entities
|
||||||
|
// Note: Cases and Orders link TO Opportunity via their OpportunityId field
|
||||||
|
whmcsServiceId: z.number().int().optional(),
|
||||||
|
|
||||||
|
// Cancellation fields (updated by CS when processing cancellation Case)
|
||||||
|
cancellationNotice: z.enum(CANCELLATION_NOTICE_VALUES).optional(),
|
||||||
|
scheduledCancellationDate: z.string().optional(),
|
||||||
|
lineReturnStatus: z.enum(LINE_RETURN_STATUS_VALUES).optional(),
|
||||||
|
// NOTE: alternativeContactEmail and cancellationComments are on the Cancellation Case
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
createdDate: z.string(),
|
||||||
|
lastModifiedDate: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OpportunityRecord = z.infer<typeof opportunityRecordSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Create Opportunity Request Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for creating a new Opportunity
|
||||||
|
*
|
||||||
|
* Note: Opportunity Name is auto-generated by Salesforce workflow,
|
||||||
|
* so we don't need to provide account name.
|
||||||
|
*/
|
||||||
|
export const createOpportunityRequestSchema = z.object({
|
||||||
|
accountId: z.string().min(15, "Salesforce Account ID must be at least 15 characters"),
|
||||||
|
productType: z.enum(OPPORTUNITY_PRODUCT_TYPE_VALUES),
|
||||||
|
stage: z.enum(OPPORTUNITY_STAGE_VALUES),
|
||||||
|
source: z.enum(OPPORTUNITY_SOURCE_VALUES),
|
||||||
|
applicationStage: z.enum(APPLICATION_STAGE_VALUES).optional(),
|
||||||
|
closeDate: z.string().optional(),
|
||||||
|
// Note: Create Case separately with Case.OpportunityId = returned Opportunity ID
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateOpportunityRequest = z.infer<typeof createOpportunityRequestSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Update Stage Request Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for updating Opportunity stage
|
||||||
|
*/
|
||||||
|
export const updateOpportunityStageRequestSchema = z.object({
|
||||||
|
opportunityId: z.string().min(15, "Salesforce Opportunity ID must be at least 15 characters"),
|
||||||
|
stage: z.enum(OPPORTUNITY_STAGE_VALUES),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateOpportunityStageRequest = z.infer<typeof updateOpportunityStageRequestSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cancellation Form Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex for YYYY-MM format
|
||||||
|
*/
|
||||||
|
const CANCELLATION_MONTH_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for cancellation form data from customer
|
||||||
|
*
|
||||||
|
* Validates:
|
||||||
|
* - cancellationMonth is in YYYY-MM format
|
||||||
|
* - Both confirmations are checked
|
||||||
|
* - Email is valid if provided
|
||||||
|
*/
|
||||||
|
export const cancellationFormDataSchema = z
|
||||||
|
.object({
|
||||||
|
cancellationMonth: z
|
||||||
|
.string()
|
||||||
|
.regex(CANCELLATION_MONTH_REGEX, "Cancellation month must be in YYYY-MM format"),
|
||||||
|
confirmTermsRead: z.boolean(),
|
||||||
|
confirmMonthEndCancellation: z.boolean(),
|
||||||
|
alternativeEmail: z.string().email().optional().or(z.literal("")),
|
||||||
|
comments: z.string().max(2000, "Comments must be 2000 characters or less").optional(),
|
||||||
|
})
|
||||||
|
.refine(data => data.confirmTermsRead === true, {
|
||||||
|
message: "You must confirm you have read the cancellation terms",
|
||||||
|
path: ["confirmTermsRead"],
|
||||||
|
})
|
||||||
|
.refine(data => data.confirmMonthEndCancellation === true, {
|
||||||
|
message: "You must confirm you understand cancellation is at month end",
|
||||||
|
path: ["confirmMonthEndCancellation"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CancellationFormData = z.infer<typeof cancellationFormDataSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for cancellation data to populate on Opportunity
|
||||||
|
*/
|
||||||
|
export const cancellationOpportunityDataSchema = z.object({
|
||||||
|
scheduledCancellationDate: z.string(),
|
||||||
|
cancellationNotice: z.enum(CANCELLATION_NOTICE_VALUES),
|
||||||
|
lineReturnStatus: z.enum(LINE_RETURN_STATUS_VALUES),
|
||||||
|
alternativeEmail: z.string().email().optional(),
|
||||||
|
comments: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CancellationOpportunityData = z.infer<typeof cancellationOpportunityDataSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cancellation Eligibility Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for a cancellation month option
|
||||||
|
*/
|
||||||
|
export const cancellationMonthOptionSchema = z.object({
|
||||||
|
value: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
serviceEndDate: z.string(),
|
||||||
|
rentalReturnDeadline: z.string(),
|
||||||
|
isCurrentMonth: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CancellationMonthOption = z.infer<typeof cancellationMonthOptionSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for cancellation eligibility check result
|
||||||
|
*/
|
||||||
|
export const cancellationEligibilitySchema = z.object({
|
||||||
|
canCancel: z.boolean(),
|
||||||
|
earliestCancellationMonth: z.string(),
|
||||||
|
availableMonths: z.array(cancellationMonthOptionSchema),
|
||||||
|
currentMonthDeadline: z.string().nullable(),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CancellationEligibility = z.infer<typeof cancellationEligibilitySchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cancellation Status Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for cancellation status display
|
||||||
|
*/
|
||||||
|
export const cancellationStatusSchema = z.object({
|
||||||
|
stage: z.enum(OPPORTUNITY_STAGE_VALUES),
|
||||||
|
isPending: z.boolean(),
|
||||||
|
isComplete: z.boolean(),
|
||||||
|
scheduledEndDate: z.string().optional(),
|
||||||
|
rentalReturnStatus: z.enum(LINE_RETURN_STATUS_VALUES).optional(),
|
||||||
|
rentalReturnDeadline: z.string().optional(),
|
||||||
|
hasRentalEquipment: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CancellationStatus = z.infer<typeof cancellationStatusSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Opportunity Lookup Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for Opportunity lookup criteria
|
||||||
|
*/
|
||||||
|
export const opportunityLookupCriteriaSchema = z.object({
|
||||||
|
accountId: z.string().min(15),
|
||||||
|
productType: z.enum(OPPORTUNITY_PRODUCT_TYPE_VALUES),
|
||||||
|
allowedStages: z.array(z.enum(OPPORTUNITY_STAGE_VALUES)).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OpportunityLookupCriteria = z.infer<typeof opportunityLookupCriteriaSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Opportunity Match Result Schema
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for Opportunity matching result
|
||||||
|
*/
|
||||||
|
export const opportunityMatchResultSchema = z.object({
|
||||||
|
opportunityId: z.string(),
|
||||||
|
wasCreated: z.boolean(),
|
||||||
|
previousStage: z.enum(OPPORTUNITY_STAGE_VALUES).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OpportunityMatchResult = z.infer<typeof opportunityMatchResultSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Response Schemas
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for Opportunity API response
|
||||||
|
*/
|
||||||
|
export const opportunityResponseSchema = z.object({
|
||||||
|
opportunity: opportunityRecordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for create Opportunity response
|
||||||
|
*/
|
||||||
|
export const createOpportunityResponseSchema = z.object({
|
||||||
|
opportunityId: z.string(),
|
||||||
|
stage: z.enum(OPPORTUNITY_STAGE_VALUES),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for cancellation preview response
|
||||||
|
*/
|
||||||
|
export const cancellationPreviewResponseSchema = z.object({
|
||||||
|
eligibility: cancellationEligibilitySchema,
|
||||||
|
terms: z.object({
|
||||||
|
deadlineDay: z.number(),
|
||||||
|
rentalReturnDeadlineDay: z.number(),
|
||||||
|
fullMonthCharge: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@ -16,14 +16,8 @@ import type {
|
|||||||
SalesforceProduct2WithPricebookEntries,
|
SalesforceProduct2WithPricebookEntries,
|
||||||
SalesforcePricebookEntryRecord,
|
SalesforcePricebookEntryRecord,
|
||||||
} from "../../../catalog/providers/salesforce/raw.types.js";
|
} from "../../../catalog/providers/salesforce/raw.types.js";
|
||||||
import {
|
import { defaultSalesforceOrderFieldMap, type SalesforceOrderFieldMap } from "./field-map.js";
|
||||||
defaultSalesforceOrderFieldMap,
|
import type { SalesforceOrderItemRecord, SalesforceOrderRecord } from "./raw.types.js";
|
||||||
type SalesforceOrderFieldMap,
|
|
||||||
} from "./field-map.js";
|
|
||||||
import type {
|
|
||||||
SalesforceOrderItemRecord,
|
|
||||||
SalesforceOrderRecord,
|
|
||||||
} from "./raw.types.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to get sort priority for item class
|
* Helper function to get sort priority for item class
|
||||||
@ -31,9 +25,9 @@ import type {
|
|||||||
function getItemClassSortPriority(itemClass?: string): number {
|
function getItemClassSortPriority(itemClass?: string): number {
|
||||||
if (!itemClass) return 4;
|
if (!itemClass) return 4;
|
||||||
const normalized = itemClass.toLowerCase();
|
const normalized = itemClass.toLowerCase();
|
||||||
if (normalized === 'service') return 1;
|
if (normalized === "service") return 1;
|
||||||
if (normalized === 'installation' || normalized === 'activation') return 2;
|
if (normalized === "installation" || normalized === "activation") return 2;
|
||||||
if (normalized === 'add-on') return 3;
|
if (normalized === "add-on") return 3;
|
||||||
return 4;
|
return 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,9 +38,7 @@ export function transformSalesforceOrderItem(
|
|||||||
record: SalesforceOrderItemRecord,
|
record: SalesforceOrderItemRecord,
|
||||||
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
|
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
|
||||||
): { details: OrderItemDetails; summary: OrderItemSummary } {
|
): { details: OrderItemDetails; summary: OrderItemSummary } {
|
||||||
const pricebookEntry = (record.PricebookEntry ?? null) as
|
const pricebookEntry = (record.PricebookEntry ?? null) as SalesforcePricebookEntryRecord | null;
|
||||||
| SalesforcePricebookEntryRecord
|
|
||||||
| null;
|
|
||||||
const product = pricebookEntry?.Product2 as SalesforceProduct2WithPricebookEntries | undefined;
|
const product = pricebookEntry?.Product2 as SalesforceProduct2WithPricebookEntries | undefined;
|
||||||
|
|
||||||
const orderItemFields = fieldMap.orderItem;
|
const orderItemFields = fieldMap.orderItem;
|
||||||
@ -133,6 +125,7 @@ export function transformSalesforceOrderDetails(
|
|||||||
accountId: ensureString(order.AccountId),
|
accountId: ensureString(order.AccountId),
|
||||||
accountName: ensureString(order.Account?.Name),
|
accountName: ensureString(order.Account?.Name),
|
||||||
pricebook2Id: ensureString(order.Pricebook2Id),
|
pricebook2Id: ensureString(order.Pricebook2Id),
|
||||||
|
opportunityId: ensureString(order.OpportunityId), // Linked Opportunity for lifecycle tracking
|
||||||
activationType: ensureString(order[orderFields.activationType]),
|
activationType: ensureString(order[orderFields.activationType]),
|
||||||
activationStatus: summary.activationStatus,
|
activationStatus: summary.activationStatus,
|
||||||
activationScheduledAt: ensureString(order[orderFields.activationScheduledAt]),
|
activationScheduledAt: ensureString(order[orderFields.activationScheduledAt]),
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Orders Domain - Salesforce Provider Raw Types
|
* Orders Domain - Salesforce Provider Raw Types
|
||||||
*
|
*
|
||||||
* Raw Salesforce API response types for Order and OrderItem sobjects.
|
* Raw Salesforce API response types for Order and OrderItem sobjects.
|
||||||
* Product and Pricebook types belong in the catalog domain.
|
* Product and Pricebook types belong in the catalog domain.
|
||||||
*/
|
*/
|
||||||
@ -43,7 +43,8 @@ export const salesforceOrderRecordSchema = z.object({
|
|||||||
// Note: Account nested object comes from customer domain
|
// Note: Account nested object comes from customer domain
|
||||||
Account: z.object({ Name: z.string().nullable().optional() }).nullable().optional(),
|
Account: z.object({ Name: z.string().nullable().optional() }).nullable().optional(),
|
||||||
Pricebook2Id: z.string().nullable().optional(),
|
Pricebook2Id: z.string().nullable().optional(),
|
||||||
|
OpportunityId: z.string().nullable().optional(), // Linked Opportunity for lifecycle tracking
|
||||||
|
|
||||||
// Activation fields
|
// Activation fields
|
||||||
Activation_Type__c: z.string().nullable().optional(),
|
Activation_Type__c: z.string().nullable().optional(),
|
||||||
Activation_Status__c: z.string().nullable().optional(),
|
Activation_Status__c: z.string().nullable().optional(),
|
||||||
@ -52,23 +53,23 @@ export const salesforceOrderRecordSchema = z.object({
|
|||||||
Activation_Error_Message__c: z.string().nullable().optional(),
|
Activation_Error_Message__c: z.string().nullable().optional(),
|
||||||
Activation_Last_Attempt_At__c: z.string().nullable().optional(),
|
Activation_Last_Attempt_At__c: z.string().nullable().optional(),
|
||||||
ActivatedDate: z.string().nullable().optional(),
|
ActivatedDate: z.string().nullable().optional(),
|
||||||
|
|
||||||
// Internet fields
|
// Internet fields
|
||||||
Internet_Plan_Tier__c: z.string().nullable().optional(),
|
Internet_Plan_Tier__c: z.string().nullable().optional(),
|
||||||
Installment_Plan__c: z.string().nullable().optional(),
|
Installment_Plan__c: z.string().nullable().optional(),
|
||||||
Access_Mode__c: z.string().nullable().optional(),
|
Access_Mode__c: z.string().nullable().optional(),
|
||||||
Weekend_Install__c: z.boolean().nullable().optional(),
|
Weekend_Install__c: z.boolean().nullable().optional(),
|
||||||
Hikari_Denwa__c: z.boolean().nullable().optional(),
|
Hikari_Denwa__c: z.boolean().nullable().optional(),
|
||||||
|
|
||||||
// VPN fields
|
// VPN fields
|
||||||
VPN_Region__c: z.string().nullable().optional(),
|
VPN_Region__c: z.string().nullable().optional(),
|
||||||
|
|
||||||
// SIM fields
|
// SIM fields
|
||||||
SIM_Type__c: z.string().nullable().optional(),
|
SIM_Type__c: z.string().nullable().optional(),
|
||||||
SIM_Voice_Mail__c: z.boolean().nullable().optional(),
|
SIM_Voice_Mail__c: z.boolean().nullable().optional(),
|
||||||
SIM_Call_Waiting__c: z.boolean().nullable().optional(),
|
SIM_Call_Waiting__c: z.boolean().nullable().optional(),
|
||||||
EID__c: z.string().nullable().optional(),
|
EID__c: z.string().nullable().optional(),
|
||||||
|
|
||||||
// MNP (Mobile Number Portability) fields
|
// MNP (Mobile Number Portability) fields
|
||||||
MNP_Application__c: z.string().nullable().optional(),
|
MNP_Application__c: z.string().nullable().optional(),
|
||||||
MNP_Reservation_Number__c: z.string().nullable().optional(),
|
MNP_Reservation_Number__c: z.string().nullable().optional(),
|
||||||
@ -81,18 +82,18 @@ export const salesforceOrderRecordSchema = z.object({
|
|||||||
Porting_First_Name_Katakana__c: z.string().nullable().optional(),
|
Porting_First_Name_Katakana__c: z.string().nullable().optional(),
|
||||||
Porting_Last_Name_Katakana__c: z.string().nullable().optional(),
|
Porting_Last_Name_Katakana__c: z.string().nullable().optional(),
|
||||||
Porting_Gender__c: z.string().nullable().optional(),
|
Porting_Gender__c: z.string().nullable().optional(),
|
||||||
|
|
||||||
// Billing address snapshot fields (standard Salesforce Order columns)
|
// Billing address snapshot fields (standard Salesforce Order columns)
|
||||||
BillingStreet: z.string().nullable().optional(),
|
BillingStreet: z.string().nullable().optional(),
|
||||||
BillingCity: z.string().nullable().optional(),
|
BillingCity: z.string().nullable().optional(),
|
||||||
BillingState: z.string().nullable().optional(),
|
BillingState: z.string().nullable().optional(),
|
||||||
BillingPostalCode: z.string().nullable().optional(),
|
BillingPostalCode: z.string().nullable().optional(),
|
||||||
BillingCountry: z.string().nullable().optional(),
|
BillingCountry: z.string().nullable().optional(),
|
||||||
|
|
||||||
// Other fields
|
// Other fields
|
||||||
Address_Changed__c: z.boolean().nullable().optional(),
|
Address_Changed__c: z.boolean().nullable().optional(),
|
||||||
WHMCS_Order_ID__c: z.string().nullable().optional(),
|
WHMCS_Order_ID__c: z.string().nullable().optional(),
|
||||||
|
|
||||||
// Audit fields
|
// Audit fields
|
||||||
CreatedDate: z.string().optional(),
|
CreatedDate: z.string().optional(),
|
||||||
LastModifiedDate: z.string().optional(),
|
LastModifiedDate: z.string().optional(),
|
||||||
@ -107,20 +108,26 @@ export type SalesforceOrderRecord = z.infer<typeof salesforceOrderRecordSchema>;
|
|||||||
/**
|
/**
|
||||||
* Platform Event payload for Order Fulfillment
|
* Platform Event payload for Order Fulfillment
|
||||||
*/
|
*/
|
||||||
export const salesforceOrderProvisionEventPayloadSchema = z.object({
|
export const salesforceOrderProvisionEventPayloadSchema = z
|
||||||
OrderId__c: z.string().optional(),
|
.object({
|
||||||
OrderId: z.string().optional(),
|
OrderId__c: z.string().optional(),
|
||||||
}).passthrough();
|
OrderId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export type SalesforceOrderProvisionEventPayload = z.infer<typeof salesforceOrderProvisionEventPayloadSchema>;
|
export type SalesforceOrderProvisionEventPayload = z.infer<
|
||||||
|
typeof salesforceOrderProvisionEventPayloadSchema
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Platform Event structure
|
* Platform Event structure
|
||||||
*/
|
*/
|
||||||
export const salesforceOrderProvisionEventSchema = z.object({
|
export const salesforceOrderProvisionEventSchema = z
|
||||||
payload: salesforceOrderProvisionEventPayloadSchema,
|
.object({
|
||||||
replayId: z.number().optional(),
|
payload: salesforceOrderProvisionEventPayloadSchema,
|
||||||
}).passthrough();
|
replayId: z.number().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export type SalesforceOrderProvisionEvent = z.infer<typeof salesforceOrderProvisionEventSchema>;
|
export type SalesforceOrderProvisionEvent = z.infer<typeof salesforceOrderProvisionEventSchema>;
|
||||||
|
|
||||||
@ -140,19 +147,23 @@ export type SalesforcePubSubSubscription = z.infer<typeof salesforcePubSubSubscr
|
|||||||
/**
|
/**
|
||||||
* Pub/Sub error metadata
|
* Pub/Sub error metadata
|
||||||
*/
|
*/
|
||||||
export const salesforcePubSubErrorMetadataSchema = z.object({
|
export const salesforcePubSubErrorMetadataSchema = z
|
||||||
"error-code": z.array(z.string()).optional(),
|
.object({
|
||||||
}).passthrough();
|
"error-code": z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export type SalesforcePubSubErrorMetadata = z.infer<typeof salesforcePubSubErrorMetadataSchema>;
|
export type SalesforcePubSubErrorMetadata = z.infer<typeof salesforcePubSubErrorMetadataSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pub/Sub error structure
|
* Pub/Sub error structure
|
||||||
*/
|
*/
|
||||||
export const salesforcePubSubErrorSchema = z.object({
|
export const salesforcePubSubErrorSchema = z
|
||||||
details: z.string().optional(),
|
.object({
|
||||||
metadata: salesforcePubSubErrorMetadataSchema.optional(),
|
details: z.string().optional(),
|
||||||
}).passthrough();
|
metadata: salesforcePubSubErrorMetadataSchema.optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export type SalesforcePubSubError = z.infer<typeof salesforcePubSubErrorSchema>;
|
export type SalesforcePubSubError = z.infer<typeof salesforcePubSubErrorSchema>;
|
||||||
|
|
||||||
|
|||||||
@ -123,8 +123,19 @@ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAdd
|
|||||||
// Handle config options - WHMCS expects base64 encoded serialized arrays
|
// Handle config options - WHMCS expects base64 encoded serialized arrays
|
||||||
configOptions.push(serializeWhmcsKeyValueMap(item.configOptions));
|
configOptions.push(serializeWhmcsKeyValueMap(item.configOptions));
|
||||||
|
|
||||||
|
// Build custom fields - include item-level fields plus order-level OpportunityId
|
||||||
|
const mergedCustomFields: Record<string, string> = {
|
||||||
|
...(item.customFields ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inject OpportunityId into each item's custom fields for lifecycle tracking
|
||||||
|
// This links the WHMCS service back to the Salesforce Opportunity
|
||||||
|
if (params.sfOpportunityId) {
|
||||||
|
mergedCustomFields["OpportunityId"] = params.sfOpportunityId;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle custom fields - WHMCS expects base64 encoded serialized arrays
|
// Handle custom fields - WHMCS expects base64 encoded serialized arrays
|
||||||
customFields.push(serializeWhmcsKeyValueMap(item.customFields));
|
customFields.push(serializeWhmcsKeyValueMap(mergedCustomFields));
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload: WhmcsAddOrderPayload = {
|
const payload: WhmcsAddOrderPayload = {
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Orders Domain - WHMCS Provider Raw Types
|
* Orders Domain - WHMCS Provider Raw Types
|
||||||
*
|
*
|
||||||
* Raw types for WHMCS API request/response.
|
* Raw types for WHMCS API request/response.
|
||||||
*
|
*
|
||||||
* WHMCS AddOrder API:
|
* WHMCS AddOrder API:
|
||||||
* @see https://developers.whmcs.com/api-reference/addorder/
|
* @see https://developers.whmcs.com/api-reference/addorder/
|
||||||
* Adds an order to a client. Returns orderid, serviceids, addonids, domainids, invoiceid.
|
* Adds an order to a client. Returns orderid, serviceids, addonids, domainids, invoiceid.
|
||||||
* CRITICAL: Must include qty[] parameter or no products will be added to the order!
|
* CRITICAL: Must include qty[] parameter or no products will be added to the order!
|
||||||
*
|
*
|
||||||
* WHMCS AcceptOrder API:
|
* WHMCS AcceptOrder API:
|
||||||
* @see https://developers.whmcs.com/api-reference/acceptorder/
|
* @see https://developers.whmcs.com/api-reference/acceptorder/
|
||||||
* Accepts a pending order. Parameters:
|
* Accepts a pending order. Parameters:
|
||||||
@ -35,7 +35,7 @@ export const whmcsOrderItemSchema = z.object({
|
|||||||
"biennially",
|
"biennially",
|
||||||
"triennially",
|
"triennially",
|
||||||
"onetime",
|
"onetime",
|
||||||
"free"
|
"free",
|
||||||
]),
|
]),
|
||||||
quantity: z.number().int().positive("Quantity must be positive").default(1),
|
quantity: z.number().int().positive("Quantity must be positive").default(1),
|
||||||
configOptions: z.record(z.string(), z.string()).optional(),
|
configOptions: z.record(z.string(), z.string()).optional(),
|
||||||
@ -55,6 +55,7 @@ export const whmcsAddOrderParamsSchema = z.object({
|
|||||||
promoCode: z.string().optional(),
|
promoCode: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
sfOrderId: z.string().optional(), // For tracking back to Salesforce
|
sfOrderId: z.string().optional(), // For tracking back to Salesforce
|
||||||
|
sfOpportunityId: z.string().optional(), // Salesforce Opportunity ID for lifecycle tracking
|
||||||
noinvoice: z.boolean().optional(), // Don't create invoice during provisioning
|
noinvoice: z.boolean().optional(), // Don't create invoice during provisioning
|
||||||
noinvoiceemail: z.boolean().optional(), // Suppress invoice email
|
noinvoiceemail: z.boolean().optional(), // Suppress invoice email
|
||||||
noemail: z.boolean().optional(), // Don't send any emails
|
noemail: z.boolean().optional(), // Don't send any emails
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Orders Domain - Schemas
|
* Orders Domain - Schemas
|
||||||
*
|
*
|
||||||
* Zod schemas for runtime validation of order data.
|
* Zod schemas for runtime validation of order data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -48,18 +48,20 @@ export const orderItemDetailsSchema = z.object({
|
|||||||
unitPrice: z.number().optional(),
|
unitPrice: z.number().optional(),
|
||||||
totalPrice: z.number().optional(),
|
totalPrice: z.number().optional(),
|
||||||
billingCycle: z.string().optional(),
|
billingCycle: z.string().optional(),
|
||||||
product: z.object({
|
product: z
|
||||||
id: z.string().optional(),
|
.object({
|
||||||
name: z.string().optional(),
|
id: z.string().optional(),
|
||||||
sku: z.string().optional(),
|
name: z.string().optional(),
|
||||||
itemClass: z.string().optional(),
|
sku: z.string().optional(),
|
||||||
whmcsProductId: z.string().optional(),
|
itemClass: z.string().optional(),
|
||||||
internetOfferingType: z.string().optional(),
|
whmcsProductId: z.string().optional(),
|
||||||
internetPlanTier: z.string().optional(),
|
internetOfferingType: z.string().optional(),
|
||||||
vpnRegion: z.string().optional(),
|
internetPlanTier: z.string().optional(),
|
||||||
isBundledAddon: z.boolean().optional(),
|
vpnRegion: z.string().optional(),
|
||||||
bundledAddonId: z.string().optional(),
|
isBundledAddon: z.boolean().optional(),
|
||||||
}).optional(),
|
bundledAddonId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -88,6 +90,7 @@ export const orderDetailsSchema = orderSummarySchema.extend({
|
|||||||
accountId: z.string().optional(),
|
accountId: z.string().optional(),
|
||||||
accountName: z.string().optional(),
|
accountName: z.string().optional(),
|
||||||
pricebook2Id: z.string().optional(),
|
pricebook2Id: z.string().optional(),
|
||||||
|
opportunityId: z.string().optional(), // Linked Opportunity for lifecycle tracking
|
||||||
activationType: z.string().optional(),
|
activationType: z.string().optional(),
|
||||||
activationStatus: z.string().optional(),
|
activationStatus: z.string().optional(),
|
||||||
activationScheduledAt: z.string().optional(), // IsoDateTimeString
|
activationScheduledAt: z.string().optional(), // IsoDateTimeString
|
||||||
@ -197,74 +200,70 @@ const baseCreateOrderSchema = z.object({
|
|||||||
|
|
||||||
export const createOrderRequestSchema = baseCreateOrderSchema;
|
export const createOrderRequestSchema = baseCreateOrderSchema;
|
||||||
|
|
||||||
export const orderBusinessValidationSchema =
|
export const orderBusinessValidationSchema = baseCreateOrderSchema
|
||||||
baseCreateOrderSchema
|
.extend({
|
||||||
.extend({
|
userId: z.string().uuid(),
|
||||||
userId: z.string().uuid(),
|
opportunityId: z.string().optional(),
|
||||||
opportunityId: z.string().optional(),
|
})
|
||||||
})
|
.refine(
|
||||||
.refine(
|
data => {
|
||||||
(data) => {
|
if (data.orderType === "Internet") {
|
||||||
if (data.orderType === "Internet") {
|
const mainServiceSkus = data.skus.filter(sku => {
|
||||||
const mainServiceSkus = data.skus.filter(sku => {
|
const upperSku = sku.toUpperCase();
|
||||||
const upperSku = sku.toUpperCase();
|
return (
|
||||||
return (
|
!upperSku.includes("INSTALL") &&
|
||||||
!upperSku.includes("INSTALL") &&
|
!upperSku.includes("ADDON") &&
|
||||||
!upperSku.includes("ADDON") &&
|
!upperSku.includes("ACTIVATION") &&
|
||||||
!upperSku.includes("ACTIVATION") &&
|
!upperSku.includes("FEE")
|
||||||
!upperSku.includes("FEE")
|
);
|
||||||
);
|
});
|
||||||
});
|
return mainServiceSkus.length >= 1;
|
||||||
return mainServiceSkus.length >= 1;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Internet orders must have at least one main service SKU (non-installation, non-addon)",
|
|
||||||
path: ["skus"],
|
|
||||||
}
|
}
|
||||||
)
|
return true;
|
||||||
.refine(
|
},
|
||||||
(data) => {
|
{
|
||||||
if (data.orderType === "SIM" && data.configurations) {
|
message:
|
||||||
return data.configurations.simType !== undefined;
|
"Internet orders must have at least one main service SKU (non-installation, non-addon)",
|
||||||
}
|
path: ["skus"],
|
||||||
return true;
|
}
|
||||||
},
|
)
|
||||||
{
|
.refine(
|
||||||
message: "SIM orders must specify SIM type",
|
data => {
|
||||||
path: ["configurations", "simType"],
|
if (data.orderType === "SIM" && data.configurations) {
|
||||||
|
return data.configurations.simType !== undefined;
|
||||||
}
|
}
|
||||||
)
|
return true;
|
||||||
.refine(
|
},
|
||||||
(data) => {
|
{
|
||||||
if (data.configurations?.simType === "eSIM") {
|
message: "SIM orders must specify SIM type",
|
||||||
return data.configurations.eid !== undefined && data.configurations.eid.length > 0;
|
path: ["configurations", "simType"],
|
||||||
}
|
}
|
||||||
return true;
|
)
|
||||||
},
|
.refine(
|
||||||
{
|
data => {
|
||||||
message: "eSIM orders must provide EID",
|
if (data.configurations?.simType === "eSIM") {
|
||||||
path: ["configurations", "eid"],
|
return data.configurations.eid !== undefined && data.configurations.eid.length > 0;
|
||||||
}
|
}
|
||||||
)
|
return true;
|
||||||
.refine(
|
},
|
||||||
(data) => {
|
{
|
||||||
if (data.configurations?.isMnp === "true") {
|
message: "eSIM orders must provide EID",
|
||||||
const required = [
|
path: ["configurations", "eid"],
|
||||||
"mnpNumber",
|
}
|
||||||
"portingLastName",
|
)
|
||||||
"portingFirstName",
|
.refine(
|
||||||
] as const;
|
data => {
|
||||||
return required.every(field => data.configurations?.[field] !== undefined);
|
if (data.configurations?.isMnp === "true") {
|
||||||
}
|
const required = ["mnpNumber", "portingLastName", "portingFirstName"] as const;
|
||||||
return true;
|
return required.every(field => data.configurations?.[field] !== undefined);
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "MNP orders must provide porting information",
|
|
||||||
path: ["configurations"],
|
|
||||||
}
|
}
|
||||||
);
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "MNP orders must provide porting information",
|
||||||
|
path: ["configurations"],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const sfOrderIdParamSchema = z.object({
|
export const sfOrderIdParamSchema = z.object({
|
||||||
sfOrderId: z
|
sfOrderId: z
|
||||||
@ -290,7 +289,7 @@ export const checkoutItemSchema = z.object({
|
|||||||
monthlyPrice: z.number().optional(),
|
monthlyPrice: z.number().optional(),
|
||||||
oneTimePrice: z.number().optional(),
|
oneTimePrice: z.number().optional(),
|
||||||
quantity: z.number().positive(),
|
quantity: z.number().positive(),
|
||||||
itemType: z.enum(['plan', 'installation', 'addon', 'activation', 'vpn']),
|
itemType: z.enum(["plan", "installation", "addon", "activation", "vpn"]),
|
||||||
autoAdded: z.boolean().optional(),
|
autoAdded: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -361,11 +360,7 @@ export const orderDisplayItemCategorySchema = z.enum([
|
|||||||
"other",
|
"other",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const orderDisplayItemChargeKindSchema = z.enum([
|
export const orderDisplayItemChargeKindSchema = z.enum(["monthly", "one-time", "other"]);
|
||||||
"monthly",
|
|
||||||
"one-time",
|
|
||||||
"other",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const orderDisplayItemChargeSchema = z.object({
|
export const orderDisplayItemChargeSchema = z.object({
|
||||||
kind: orderDisplayItemChargeKindSchema,
|
kind: orderDisplayItemChargeKindSchema,
|
||||||
|
|||||||
@ -79,6 +79,14 @@
|
|||||||
"import": "./dist/mappings/*.js",
|
"import": "./dist/mappings/*.js",
|
||||||
"types": "./dist/mappings/*.d.ts"
|
"types": "./dist/mappings/*.d.ts"
|
||||||
},
|
},
|
||||||
|
"./opportunity": {
|
||||||
|
"import": "./dist/opportunity/index.js",
|
||||||
|
"types": "./dist/opportunity/index.d.ts"
|
||||||
|
},
|
||||||
|
"./opportunity/*": {
|
||||||
|
"import": "./dist/opportunity/*.js",
|
||||||
|
"types": "./dist/opportunity/*.d.ts"
|
||||||
|
},
|
||||||
"./orders": {
|
"./orders": {
|
||||||
"import": "./dist/orders/index.js",
|
"import": "./dist/orders/index.js",
|
||||||
"types": "./dist/orders/index.d.ts"
|
"types": "./dist/orders/index.d.ts"
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
"customer/**/*",
|
"customer/**/*",
|
||||||
"dashboard/**/*",
|
"dashboard/**/*",
|
||||||
"mappings/**/*",
|
"mappings/**/*",
|
||||||
|
"opportunity/**/*",
|
||||||
"orders/**/*",
|
"orders/**/*",
|
||||||
"payments/**/*",
|
"payments/**/*",
|
||||||
"providers/**/*",
|
"providers/**/*",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user