Enhance Opportunity Management and Eligibility Handling
- Updated SalesforceOpportunityService to allow filtering by stages during opportunity retrieval, improving flexibility in eligibility checks. - Integrated DistributedLockService into InternetCatalogService and OrderOrchestrator to prevent race conditions when creating or reusing opportunities. - Refactored opportunity matching logic to ensure proper handling of stages during eligibility requests and order placements. - Improved documentation to clarify the opportunity lifecycle and eligibility verification processes, ensuring better understanding for future development.
This commit is contained in:
parent
90ab71b94d
commit
a61c2dd68b
@ -280,7 +280,7 @@ When running `pnpm dev:tools`, you get access to:
|
||||
|
||||
- `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert
|
||||
- `POST /api/auth/login` - Portal authentication
|
||||
- `POST /api/auth/link-whmcs` - OIDC callback or ValidateLogin
|
||||
- `POST /api/auth/migrate` - Account migration from legacy portal
|
||||
- `POST /api/auth/set-password` - Required after WHMCS link
|
||||
|
||||
### User Management
|
||||
|
||||
@ -51,7 +51,7 @@ export class CsrfMiddleware implements NestMiddleware {
|
||||
"/api/auth/request-password-reset",
|
||||
"/api/auth/reset-password", // Public auth endpoint for password reset
|
||||
"/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
|
||||
"/api/auth/link-whmcs", // Public auth endpoint for WHMCS account linking
|
||||
"/api/auth/migrate", // Public auth endpoint for account migration
|
||||
"/api/health",
|
||||
"/docs",
|
||||
"/api/webhooks", // Webhooks typically don't use CSRF
|
||||
|
||||
@ -304,23 +304,28 @@ export class SalesforceOpportunityService {
|
||||
*/
|
||||
async findOpenOpportunityForAccount(
|
||||
accountId: string,
|
||||
productType: OpportunityProductTypeValue
|
||||
productType: OpportunityProductTypeValue,
|
||||
options?: { stages?: OpportunityStageValue[] }
|
||||
): Promise<string | null> {
|
||||
const safeAccountId = assertSalesforceId(accountId, "accountId");
|
||||
|
||||
// Get the CommodityType value(s) that match this product type
|
||||
const commodityTypeValues = this.getCommodityTypesForProductType(productType);
|
||||
|
||||
const stages =
|
||||
Array.isArray(options?.stages) && options?.stages.length > 0
|
||||
? options.stages
|
||||
: OPEN_OPPORTUNITY_STAGES;
|
||||
|
||||
this.logger.debug("Looking for open Opportunity", {
|
||||
accountId: safeAccountId,
|
||||
productType,
|
||||
commodityTypes: commodityTypeValues,
|
||||
stages,
|
||||
});
|
||||
|
||||
// Build stage filter for open stages
|
||||
const stageList = OPEN_OPPORTUNITY_STAGES.map((s: OpportunityStageValue) => `'${s}'`).join(
|
||||
", "
|
||||
);
|
||||
const stageList = stages.map((s: OpportunityStageValue) => `'${s}'`).join(", ");
|
||||
const commodityTypeList = commodityTypeValues.map(ct => `'${ct}'`).join(", ");
|
||||
|
||||
const soql = `
|
||||
|
||||
@ -230,11 +230,11 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("link-whmcs")
|
||||
@Post("migrate")
|
||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
|
||||
@UsePipes(new ZodValidationPipe(linkWhmcsRequestSchema))
|
||||
async linkWhmcs(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
|
||||
async migrateAccount(@Body() linkData: LinkWhmcsRequest, @Req() _req: Request) {
|
||||
const result = await this.authFacade.linkWhmcsUser(linkData);
|
||||
return linkWhmcsResponseSchema.parse(result);
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.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 { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js";
|
||||
@ -28,6 +29,7 @@ import {
|
||||
OPPORTUNITY_STAGE,
|
||||
OPPORTUNITY_SOURCE,
|
||||
OPPORTUNITY_PRODUCT_TYPE,
|
||||
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
|
||||
} from "@customer-portal/domain/opportunity";
|
||||
|
||||
export type InternetEligibilityStatusDto = "not_requested" | "pending" | "eligible" | "ineligible";
|
||||
@ -49,6 +51,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
@Inject(Logger) logger: Logger,
|
||||
private mappingsService: MappingsService,
|
||||
private catalogCache: CatalogCacheService,
|
||||
private lockService: DistributedLockService,
|
||||
private opportunityService: SalesforceOpportunityService,
|
||||
private caseService: SalesforceCaseService
|
||||
) {
|
||||
@ -278,67 +281,88 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 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
|
||||
const lockKey = `internet:eligibility:${sfAccountId}`;
|
||||
|
||||
const caseId = await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
// Idempotency: if we already have a pending request, return the existing request id.
|
||||
const existing = await this.queryEligibilityDetails(sfAccountId);
|
||||
if (existing.status === "pending" && existing.requestId) {
|
||||
this.logger.log("Eligibility request already pending; returning existing request id", {
|
||||
userId,
|
||||
sfAccountIdTail: sfAccountId.slice(-4),
|
||||
caseIdTail: existing.requestId.slice(-4),
|
||||
});
|
||||
return existing.requestId;
|
||||
}
|
||||
|
||||
// 1) Find or create Opportunity for Internet eligibility
|
||||
// Only match Introduction stage. "Ready"/"Post Processing"/"Active" indicate the journey progressed.
|
||||
let opportunityId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||
sfAccountId,
|
||||
OPPORTUNITY_PRODUCT_TYPE.INTERNET,
|
||||
{ stages: OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY }
|
||||
);
|
||||
|
||||
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 createdCaseId = await this.caseService.createEligibilityCase({
|
||||
accountId: sfAccountId,
|
||||
opportunityId,
|
||||
subject,
|
||||
description: descriptionLines.join("\n"),
|
||||
});
|
||||
|
||||
// 4) Update Account eligibility status
|
||||
await this.updateAccountEligibilityRequestState(sfAccountId, createdCaseId);
|
||||
await this.catalogCache.invalidateEligibility(sfAccountId);
|
||||
|
||||
this.logger.log("Created eligibility Case linked to Opportunity", {
|
||||
userId,
|
||||
sfAccountIdTail: sfAccountId.slice(-4),
|
||||
caseIdTail: createdCaseId.slice(-4),
|
||||
opportunityIdTail: opportunityId.slice(-4),
|
||||
opportunityCreated,
|
||||
});
|
||||
|
||||
return createdCaseId;
|
||||
},
|
||||
{ ttlMs: 10_000 }
|
||||
);
|
||||
|
||||
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,
|
||||
description: descriptionLines.join("\n"),
|
||||
});
|
||||
|
||||
// 4. Update Account eligibility status
|
||||
await this.updateAccountEligibilityRequestState(sfAccountId, caseId);
|
||||
|
||||
await this.catalogCache.invalidateEligibility(sfAccountId);
|
||||
|
||||
this.logger.log("Created eligibility Case linked to Opportunity", {
|
||||
userId,
|
||||
sfAccountIdTail: sfAccountId.slice(-4),
|
||||
caseIdTail: caseId.slice(-4),
|
||||
opportunityIdTail: opportunityId.slice(-4),
|
||||
opportunityCreated,
|
||||
});
|
||||
|
||||
return caseId;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create eligibility request", {
|
||||
|
||||
@ -129,7 +129,8 @@ export class NotificationService {
|
||||
userId,
|
||||
expiresAt: { gt: now },
|
||||
...(options?.includeDismissed ? {} : { dismissed: false }),
|
||||
...(options?.includeRead ? {} : {}), // Include all by default for the list
|
||||
// By default we include read notifications. If includeRead=false, filter them out.
|
||||
...(options?.includeRead === false ? { read: false } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@ -1,561 +0,0 @@
|
||||
/**
|
||||
* 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 { DistributedLockService } from "@bff/infra/cache/distributed-lock.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,
|
||||
private readonly lockService: DistributedLockService,
|
||||
@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.
|
||||
*
|
||||
* Uses a distributed lock to prevent race conditions when multiple
|
||||
* concurrent requests try to create Opportunities for the same account.
|
||||
*
|
||||
* @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");
|
||||
}
|
||||
|
||||
// Use distributed lock to prevent race conditions
|
||||
// Lock key is specific to account + product type
|
||||
const lockKey = `opportunity:${context.accountId}:${productType}`;
|
||||
|
||||
return this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
// Re-check for existing Opportunity after acquiring lock
|
||||
// Another request may have created one while we were waiting
|
||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||
context.accountId,
|
||||
productType
|
||||
);
|
||||
|
||||
if (existingOppId) {
|
||||
this.logger.debug("Found existing Opportunity after acquiring lock", {
|
||||
opportunityId: existingOppId,
|
||||
});
|
||||
return this.useExistingOpportunity(existingOppId);
|
||||
}
|
||||
|
||||
// No existing Opportunity found - create new one
|
||||
return this.createNewOpportunity(context, productType);
|
||||
},
|
||||
{ ttlMs: 10_000 } // 10 second lock TTL
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Opportunity Creation Triggers
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create Opportunity at eligibility request (Internet only)
|
||||
*
|
||||
* Called when customer requests Internet eligibility check.
|
||||
* Uses distributed lock to prevent duplicate Opportunities.
|
||||
* First checks for existing open Opportunity before creating.
|
||||
*
|
||||
* 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 Opportunity ID (existing or newly created)
|
||||
*/
|
||||
async createOpportunityForEligibility(accountId: string): Promise<string> {
|
||||
this.logger.log("Creating Opportunity for Internet eligibility request", {
|
||||
accountId,
|
||||
});
|
||||
|
||||
const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.INTERNET}`;
|
||||
|
||||
return this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
// Check for existing open Opportunity first
|
||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||
accountId,
|
||||
OPPORTUNITY_PRODUCT_TYPE.INTERNET
|
||||
);
|
||||
|
||||
if (existingOppId) {
|
||||
this.logger.debug("Found existing Internet Opportunity, reusing", {
|
||||
opportunityId: existingOppId,
|
||||
});
|
||||
return existingOppId;
|
||||
}
|
||||
|
||||
// Create new Opportunity
|
||||
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;
|
||||
},
|
||||
{ ttlMs: 10_000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Opportunity at checkout registration (SIM only)
|
||||
*
|
||||
* Called when customer creates account during SIM checkout.
|
||||
* Uses distributed lock to prevent duplicate Opportunities.
|
||||
* First checks for existing open Opportunity before creating.
|
||||
*
|
||||
* NOTE: Opportunity Name is auto-generated by Salesforce workflow.
|
||||
*
|
||||
* @param accountId - Salesforce Account ID
|
||||
* @returns Opportunity ID (existing or newly created)
|
||||
*/
|
||||
async createOpportunityForCheckoutRegistration(accountId: string): Promise<string> {
|
||||
this.logger.log("Creating Opportunity for SIM checkout registration", {
|
||||
accountId,
|
||||
});
|
||||
|
||||
const lockKey = `opportunity:${accountId}:${OPPORTUNITY_PRODUCT_TYPE.SIM}`;
|
||||
|
||||
return this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
// Check for existing open Opportunity first
|
||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||
accountId,
|
||||
OPPORTUNITY_PRODUCT_TYPE.SIM
|
||||
);
|
||||
|
||||
if (existingOppId) {
|
||||
this.logger.debug("Found existing SIM Opportunity, reusing", {
|
||||
opportunityId: existingOppId,
|
||||
});
|
||||
return existingOppId;
|
||||
}
|
||||
|
||||
// Create new Opportunity
|
||||
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;
|
||||
},
|
||||
{ ttlMs: 10_000 }
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { Injectable, Inject, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceOrderService } from "@bff/integrations/salesforce/services/salesforce-order.service.js";
|
||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||
import { DistributedLockService } from "@bff/infra/cache/distributed-lock.service.js";
|
||||
import { OrderValidator } from "./order-validator.service.js";
|
||||
import { OrderBuilder } from "./order-builder.service.js";
|
||||
import { OrderItemBuilder } from "./order-item-builder.service.js";
|
||||
@ -14,6 +15,7 @@ import {
|
||||
OPPORTUNITY_SOURCE,
|
||||
OPPORTUNITY_PRODUCT_TYPE,
|
||||
type OpportunityProductTypeValue,
|
||||
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
|
||||
} from "@customer-portal/domain/opportunity";
|
||||
|
||||
type OrderDetailsResponse = OrderDetails;
|
||||
@ -29,6 +31,7 @@ export class OrderOrchestrator {
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly salesforceOrderService: SalesforceOrderService,
|
||||
private readonly opportunityService: SalesforceOpportunityService,
|
||||
private readonly lockService: DistributedLockService,
|
||||
private readonly orderValidator: OrderValidator,
|
||||
private readonly orderBuilder: OrderBuilder,
|
||||
private readonly orderItemBuilder: OrderItemBuilder,
|
||||
@ -163,34 +166,43 @@ export class OrderOrchestrator {
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to find existing open Opportunity (Introduction or Ready stage)
|
||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||
safeAccountId,
|
||||
productType
|
||||
const lockKey = `opportunity:order:${safeAccountId}:${productType}`;
|
||||
|
||||
return await this.lockService.withLock(
|
||||
lockKey,
|
||||
async () => {
|
||||
// Try to find existing matchable Opportunity (Introduction or Ready stage)
|
||||
const existingOppId = await this.opportunityService.findOpenOpportunityForAccount(
|
||||
safeAccountId,
|
||||
productType,
|
||||
{ stages: OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT }
|
||||
);
|
||||
|
||||
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;
|
||||
},
|
||||
{ ttlMs: 10_000 }
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import LinkWhmcsView from "@/features/auth/views/LinkWhmcsView";
|
||||
|
||||
export default function LinkWhmcsPage() {
|
||||
return <LinkWhmcsView />;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import MigrateAccountView from "@/features/auth/views/MigrateAccountView";
|
||||
|
||||
export default function MigrateAccountPage() {
|
||||
return <MigrateAccountView />;
|
||||
}
|
||||
@ -11,6 +11,8 @@ export interface AuthLayoutProps {
|
||||
showBackButton?: boolean;
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
/** Use wider layout for forms with more content like signup */
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
export function AuthLayout({
|
||||
@ -20,10 +22,13 @@ export function AuthLayout({
|
||||
showBackButton = false,
|
||||
backHref = "/",
|
||||
backLabel = "Back to Home",
|
||||
wide = false,
|
||||
}: AuthLayoutProps) {
|
||||
const maxWidth = wide ? "max-w-xl" : "max-w-md";
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className={`w-full ${maxWidth}`}>
|
||||
{showBackButton && (
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
@ -56,7 +61,7 @@ export function AuthLayout({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 w-full max-w-md">
|
||||
<div className={`mt-8 w-full ${maxWidth}`}>
|
||||
<div className="relative">
|
||||
{/* Subtle gradient glow behind card */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-primary/10 via-transparent to-primary/10 rounded-[1.75rem] blur-xl opacity-50" />
|
||||
|
||||
@ -76,33 +76,45 @@ export function InlineAuthSection({
|
||||
</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 className="bg-card border border-border rounded-xl p-5 sm:p-6 shadow-[var(--cp-shadow-1)]">
|
||||
{mode === "signup" && (
|
||||
<>
|
||||
<h4 className="text-base font-semibold text-foreground mb-2">Create your account</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Set up your portal access in a few simple steps.
|
||||
</p>
|
||||
<SignupForm redirectTo={redirectTo} showFooterLinks={false} />
|
||||
</>
|
||||
)}
|
||||
{mode === "login" && (
|
||||
<>
|
||||
<h4 className="text-base font-semibold text-foreground mb-2">Sign in</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">Access your account to continue.</p>
|
||||
<LoginForm redirectTo={redirectTo} showSignupLink={false} />
|
||||
</>
|
||||
)}
|
||||
{mode === "migrate" && (
|
||||
<>
|
||||
<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 && (
|
||||
|
||||
@ -83,7 +83,7 @@ export function LoginForm({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-md mx-auto ${className}`}>
|
||||
<div className={`w-full ${className}`}>
|
||||
<form onSubmit={event => void handleSubmit(event)} className="space-y-6">
|
||||
<FormField label="Email Address" error={touched.email ? errors.email : undefined} required>
|
||||
<Input
|
||||
|
||||
@ -44,14 +44,13 @@ export function MultiStepForm({
|
||||
onStepChange?.(currentStep);
|
||||
}, [currentStep, onStepChange]);
|
||||
|
||||
const totalSteps = steps.length;
|
||||
const step = steps[currentStep] ?? steps[0];
|
||||
const isFirstStep = currentStep === 0;
|
||||
const disableNext = isSubmitting || (!canProceed && !isLastStep);
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* Step Indicators */}
|
||||
{/* Simple Step Indicators */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{steps.map((s, idx) => {
|
||||
const isCompleted = idx < currentStep;
|
||||
@ -61,20 +60,20 @@ export function MultiStepForm({
|
||||
<div key={s.key} className="flex items-center">
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium
|
||||
transition-all duration-200
|
||||
flex items-center justify-center w-8 h-8 rounded-full text-sm font-semibold
|
||||
transition-colors duration-200
|
||||
${
|
||||
isCompleted
|
||||
? "bg-success text-success-foreground"
|
||||
: isCurrent
|
||||
? "bg-primary text-primary-foreground ring-4 ring-primary/15"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? <CheckIcon className="w-4 h-4" /> : idx + 1}
|
||||
</div>
|
||||
{idx < totalSteps - 1 && (
|
||||
{idx < steps.length - 1 && (
|
||||
<div
|
||||
className={`w-8 h-0.5 mx-1 transition-colors duration-200 ${
|
||||
isCompleted ? "bg-success" : "bg-border"
|
||||
|
||||
@ -27,11 +27,17 @@ import { PasswordStep } from "./steps/PasswordStep";
|
||||
* - confirmPassword: UI-only field for password confirmation
|
||||
* - phoneCountryCode: Separate field for country code input
|
||||
* - address: Required addressFormSchema (domain schema makes it optional)
|
||||
* - dateOfBirth: Required for signup (domain schema makes it optional)
|
||||
* - gender: Required for signup (domain schema makes it optional)
|
||||
*/
|
||||
const genderSchema = z.enum(["male", "female", "other"]);
|
||||
|
||||
const signupFormBaseSchema = signupInputSchema.omit({ sfNumber: true }).extend({
|
||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
||||
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/, "Enter a valid country code (e.g., +81)"),
|
||||
address: addressFormSchema,
|
||||
dateOfBirth: z.string().min(1, "Date of birth is required"),
|
||||
gender: genderSchema,
|
||||
});
|
||||
|
||||
const signupFormSchema = signupFormBaseSchema
|
||||
@ -132,8 +138,8 @@ export function SignupForm({
|
||||
phone: "",
|
||||
phoneCountryCode: "+81",
|
||||
company: "",
|
||||
dateOfBirth: undefined,
|
||||
gender: undefined,
|
||||
dateOfBirth: "",
|
||||
gender: "" as unknown as "male" | "female" | "other", // Will be validated on submit
|
||||
address: {
|
||||
address1: "",
|
||||
address2: "",
|
||||
@ -257,47 +263,45 @@ export function SignupForm({
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={`w-full max-w-lg mx-auto ${className}`}>
|
||||
<div className="bg-card text-card-foreground shadow-[var(--cp-shadow-2)] rounded-2xl border border-border p-6 sm:p-8">
|
||||
<MultiStepForm
|
||||
steps={steps}
|
||||
currentStep={step}
|
||||
onNext={handleNext}
|
||||
onPrevious={handlePrevious}
|
||||
isLastStep={isLastStep}
|
||||
isSubmitting={isSubmitting || loading}
|
||||
canProceed={isLastStep || isStepValid(step)}
|
||||
/>
|
||||
<div className={`w-full ${className}`}>
|
||||
<MultiStepForm
|
||||
steps={steps}
|
||||
currentStep={step}
|
||||
onNext={handleNext}
|
||||
onPrevious={handlePrevious}
|
||||
isLastStep={isLastStep}
|
||||
isSubmitting={isSubmitting || loading}
|
||||
canProceed={isLastStep || isStepValid(step)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<ErrorMessage className="mt-4 text-center p-3 bg-danger-soft rounded-lg">
|
||||
{error}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
{error && (
|
||||
<ErrorMessage className="mt-4 text-center p-3 bg-danger-soft rounded-lg">
|
||||
{error}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
|
||||
{showFooterLinks && (
|
||||
<div className="mt-6 text-center border-t border-border pt-6 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href={`/auth/login${redirectQuery}`}
|
||||
className="font-medium text-primary hover:underline transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Existing customer?{" "}
|
||||
<Link
|
||||
href="/auth/link-whmcs"
|
||||
className="font-medium text-primary hover:underline transition-colors"
|
||||
>
|
||||
Migrate your account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showFooterLinks && (
|
||||
<div className="mt-6 text-center border-t border-border pt-6 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href={`/auth/login${redirectQuery}`}
|
||||
className="font-medium text-primary hover:underline transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Existing customer?{" "}
|
||||
<Link
|
||||
href="/auth/migrate"
|
||||
className="font-medium text-primary hover:underline transition-colors"
|
||||
>
|
||||
Migrate your account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
|
||||
@ -30,7 +29,6 @@ interface AccountStepProps {
|
||||
export function AccountStep({ form }: AccountStepProps) {
|
||||
const { values, errors, touched, setValue, setTouchedField } = form;
|
||||
const getError = (field: string) => (touched[field] ? errors[field] : undefined);
|
||||
const [showOptional, setShowOptional] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@ -112,69 +110,55 @@ export function AccountStep({ form }: AccountStepProps) {
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
onClick={() => setShowOptional(s => !s)}
|
||||
>
|
||||
{showOptional ? "Hide optional details" : "Add optional details"}
|
||||
</button>
|
||||
{/* DOB + Gender (Required) */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Date of Birth" error={getError("dateOfBirth")} required>
|
||||
<Input
|
||||
name="bday"
|
||||
type="date"
|
||||
value={values.dateOfBirth ?? ""}
|
||||
onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
|
||||
onBlur={() => setTouchedField("dateOfBirth")}
|
||||
autoComplete="bday"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Gender" error={getError("gender")} required>
|
||||
<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>
|
||||
|
||||
{showOptional && (
|
||||
<div className="space-y-5">
|
||||
{/* DOB + Gender (Optional WHMCS custom fields) */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Date of Birth" error={getError("dateOfBirth")} helperText="Optional">
|
||||
<Input
|
||||
name="bday"
|
||||
type="date"
|
||||
value={values.dateOfBirth ?? ""}
|
||||
onChange={e => setValue("dateOfBirth", e.target.value || undefined)}
|
||||
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>
|
||||
)}
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -280,7 +280,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
linkWhmcs: async (linkRequest: LinkWhmcsRequest) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.POST("/api/auth/link-whmcs", {
|
||||
const response = await apiClient.POST("/api/auth/migrate", {
|
||||
body: linkRequest,
|
||||
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Link WHMCS View - Account migration page
|
||||
* Migrate Account View - Account migration page
|
||||
*/
|
||||
|
||||
"use client";
|
||||
@ -10,7 +10,7 @@ import { AuthLayout } from "../components";
|
||||
import { LinkWhmcsForm } from "@/features/auth/components";
|
||||
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
|
||||
|
||||
export function LinkWhmcsView() {
|
||||
export function MigrateAccountView() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
@ -32,7 +32,7 @@ export function LinkWhmcsView() {
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-card text-card-foreground rounded-lg border border-border p-6 shadow-[var(--cp-shadow-1)]">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-1">
|
||||
Enter Legacy Portal Credentials
|
||||
</h2>
|
||||
@ -51,7 +51,7 @@ export function LinkWhmcsView() {
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex justify-center gap-6 text-sm">
|
||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
New customer?{" "}
|
||||
<Link href="/auth/signup" className="text-primary hover:underline">
|
||||
@ -92,4 +92,4 @@ export function LinkWhmcsView() {
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkWhmcsView;
|
||||
export default MigrateAccountView;
|
||||
@ -15,7 +15,7 @@ function SetPasswordContent() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!email) {
|
||||
router.replace("/auth/link-whmcs");
|
||||
router.replace("/auth/migrate");
|
||||
}
|
||||
}, [email, router]);
|
||||
|
||||
@ -36,7 +36,7 @@ function SetPasswordContent() {
|
||||
again so we can verify your account.
|
||||
</p>
|
||||
<Link
|
||||
href="/auth/link-whmcs"
|
||||
href="/auth/migrate"
|
||||
className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
|
||||
>
|
||||
Go to account transfer
|
||||
|
||||
@ -13,6 +13,7 @@ export function SignupView() {
|
||||
<AuthLayout
|
||||
title="Create Your Account"
|
||||
subtitle="Set up your portal access in a few simple steps"
|
||||
wide
|
||||
>
|
||||
<SignupForm />
|
||||
</AuthLayout>
|
||||
|
||||
@ -3,4 +3,4 @@ export { SignupView } from "./SignupView";
|
||||
export { ForgotPasswordView } from "./ForgotPasswordView";
|
||||
export { ResetPasswordView } from "./ResetPasswordView";
|
||||
export { SetPasswordView } from "./SetPasswordView";
|
||||
export { LinkWhmcsView } from "./LinkWhmcsView";
|
||||
export { MigrateAccountView } from "./MigrateAccountView";
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
||||
import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge";
|
||||
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
|
||||
import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog";
|
||||
|
||||
interface PlanHeaderProps {
|
||||
plan: InternetPlanCatalogItem;
|
||||
backHref?: string;
|
||||
backLabel?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PlanHeader({
|
||||
plan,
|
||||
backHref,
|
||||
backLabel = "Back to Internet Plans",
|
||||
title = "Configure your plan",
|
||||
className = "",
|
||||
}: PlanHeaderProps) {
|
||||
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
|
||||
|
||||
return (
|
||||
<div className={`text-center mb-8 animate-in fade-in duration-300 ${className}`}>
|
||||
{backHref && (
|
||||
<Button
|
||||
as="a"
|
||||
href={backHref}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
className="mb-6 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{backLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-5">{title}</h1>
|
||||
<span className="sr-only">
|
||||
{planBaseName}
|
||||
{planDetail ? ` (${planDetail})` : ""}
|
||||
</span>
|
||||
|
||||
<div className="inline-flex flex-wrap items-center justify-center gap-3 bg-card px-6 py-3 rounded-full border border-border shadow-sm text-sm">
|
||||
{plan.internetPlanTier ? (
|
||||
<CardBadge
|
||||
text={plan.internetPlanTier}
|
||||
variant={getTierBadgeVariant(plan.internetPlanTier)}
|
||||
size="sm"
|
||||
/>
|
||||
) : null}
|
||||
{planDetail ? <CardBadge text={planDetail} variant="family" size="sm" /> : null}
|
||||
{plan.monthlyPrice && plan.monthlyPrice > 0 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-semibold text-primary">
|
||||
¥{plan.monthlyPrice.toLocaleString()}/month
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTierBadgeVariant(tier?: string | null): BadgeVariant {
|
||||
switch (tier) {
|
||||
case "Gold":
|
||||
return "gold";
|
||||
case "Platinum":
|
||||
return "platinum";
|
||||
case "Silver":
|
||||
return "silver";
|
||||
case "Recommended":
|
||||
return "recommended";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,7 @@
|
||||
import { useEffect, useState, type ReactElement } from "react";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { ProgressSteps } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { CardBadge } from "@/features/catalog/components/base/CardBadge";
|
||||
import type { BadgeVariant } from "@/features/catalog/components/base/CardBadge";
|
||||
import { ServerIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { ServerIcon } from "@heroicons/react/24/outline";
|
||||
import type {
|
||||
InternetPlanCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
@ -19,8 +16,8 @@ import { InstallationStep } from "./steps/InstallationStep";
|
||||
import { AddonsStep } from "./steps/AddonsStep";
|
||||
import { ReviewOrderStep } from "./steps/ReviewOrderStep";
|
||||
import { useConfigureState } from "./hooks/useConfigureState";
|
||||
import { parsePlanName } from "@/features/catalog/components/internet/utils/planName";
|
||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||
import { PlanHeader } from "@/features/catalog/components/internet/PlanHeader";
|
||||
|
||||
interface Props {
|
||||
plan: InternetPlanCatalogItem | null;
|
||||
@ -61,6 +58,7 @@ export function InternetConfigureContainer({
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
}: Props) {
|
||||
const shopBasePath = useShopBasePath();
|
||||
const [renderedStep, setRenderedStep] = useState(currentStep);
|
||||
const [transitionPhase, setTransitionPhase] = useState<"idle" | "enter" | "exit">("idle");
|
||||
// Use local state ONLY for step validation, step management now in Zustand
|
||||
@ -214,7 +212,11 @@ export function InternetConfigureContainer({
|
||||
<div className="min-h-[70vh]">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Plan Header */}
|
||||
<PlanHeader plan={plan} />
|
||||
<PlanHeader
|
||||
plan={plan}
|
||||
backHref={`${shopBasePath}/internet`}
|
||||
backLabel="Back to Internet Plans"
|
||||
/>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-10">
|
||||
@ -230,60 +232,3 @@ export function InternetConfigureContainer({
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanHeader({ plan }: { plan: InternetPlanCatalogItem }) {
|
||||
const shopBasePath = useShopBasePath();
|
||||
const { baseName: planBaseName, detail: planDetail } = parsePlanName(plan);
|
||||
|
||||
return (
|
||||
<div className="text-center mb-8 animate-in fade-in duration-300">
|
||||
<Button
|
||||
as="a"
|
||||
href={`${shopBasePath}/internet`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
className="mb-6 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Back to Internet Plans
|
||||
</Button>
|
||||
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-5">Configure your plan</h1>
|
||||
<span className="sr-only">
|
||||
{planBaseName}
|
||||
{planDetail ? ` (${planDetail})` : ""}
|
||||
</span>
|
||||
|
||||
<div className="inline-flex flex-wrap items-center justify-center gap-3 bg-card px-6 py-3 rounded-full border border-border shadow-sm text-sm">
|
||||
{plan.internetPlanTier ? (
|
||||
<CardBadge
|
||||
text={plan.internetPlanTier}
|
||||
variant={getTierBadgeVariant(plan.internetPlanTier)}
|
||||
size="sm"
|
||||
/>
|
||||
) : null}
|
||||
{planDetail ? <CardBadge text={planDetail} variant="family" size="sm" /> : null}
|
||||
{plan.monthlyPrice && plan.monthlyPrice > 0 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-semibold text-primary">
|
||||
¥{plan.monthlyPrice.toLocaleString()}/month
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTierBadgeVariant(tier?: string | null): BadgeVariant {
|
||||
switch (tier) {
|
||||
case "Gold":
|
||||
return "gold";
|
||||
case "Platinum":
|
||||
return "platinum";
|
||||
case "Silver":
|
||||
return "silver";
|
||||
case "Recommended":
|
||||
return "recommended";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,7 +69,10 @@ export function InternetPlansContainer() {
|
||||
const isPending = eligibilityStatus === "pending";
|
||||
const isNotRequested = eligibilityStatus === "not_requested";
|
||||
const isIneligible = eligibilityStatus === "ineligible";
|
||||
const orderingLocked = isPending || isNotRequested || isIneligible;
|
||||
|
||||
// While loading eligibility, we assume locked to prevent showing unfiltered catalog for a split second
|
||||
const orderingLocked =
|
||||
eligibilityLoading || isPending || isNotRequested || isIneligible || !eligibilityStatus;
|
||||
const hasServiceAddress = Boolean(
|
||||
user?.address?.address1 &&
|
||||
user?.address?.city &&
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { CheckIcon, ServerIcon } from "@heroicons/react/24/outline";
|
||||
import { WifiIcon, CheckCircleIcon, ClockIcon, BellIcon } from "@heroicons/react/24/outline";
|
||||
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
|
||||
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
|
||||
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
||||
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
|
||||
|
||||
/**
|
||||
* Public Internet Configure View
|
||||
*
|
||||
* Shows selected plan information and prompts for authentication via modal.
|
||||
* Much better UX than redirecting to a full signup page.
|
||||
* Generic signup flow for internet availability check.
|
||||
* Focuses on account creation, not plan details.
|
||||
*/
|
||||
export function PublicInternetConfigureView() {
|
||||
const shopBasePath = useShopBasePath();
|
||||
@ -23,74 +22,45 @@ export function PublicInternetConfigureView() {
|
||||
: "/account/shop/internet?autoEligibilityRequest=1";
|
||||
|
||||
return (
|
||||
<>
|
||||
<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" />
|
||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<CatalogBackLink href={`${shopBasePath}/internet`} label="Back to plans" />
|
||||
|
||||
<CatalogHero
|
||||
title="Check availability for your address"
|
||||
description="Create an account so we can verify service availability. We'll submit your request automatically."
|
||||
/>
|
||||
|
||||
<div className="mt-8 space-y-8">
|
||||
<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-shrink-0">
|
||||
<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" />
|
||||
</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 className="border-t border-border pt-6 mt-6">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">
|
||||
What happens next
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
<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">
|
||||
Create your account and confirm your service address.
|
||||
</span>
|
||||
</li>
|
||||
<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">
|
||||
We submit an availability request to our team.
|
||||
</span>
|
||||
</li>
|
||||
<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">
|
||||
You'll be notified when your personalized plans are ready.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="mt-6 mb-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 border border-primary/20">
|
||||
<WifiIcon className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">Check Internet Availability</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create an account to verify service availability at your address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InlineAuthSection
|
||||
title="Ready to get started?"
|
||||
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."
|
||||
redirectTo={redirectTo}
|
||||
highlights={[
|
||||
{ title: "Verify Availability", description: "Check service at your address" },
|
||||
{ title: "Personalized Plans", description: "See plans tailored to you" },
|
||||
{ title: "Secure Ordering", description: "Complete your order safely" },
|
||||
]}
|
||||
/>
|
||||
{/* Process Steps - Compact */}
|
||||
<div className="mb-8 grid grid-cols-3 gap-3 text-center">
|
||||
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<CheckCircleIcon className="h-5 w-5 text-success" />
|
||||
<span className="text-xs font-medium text-foreground">Create account</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<ClockIcon className="h-5 w-5 text-info" />
|
||||
<span className="text-xs font-medium text-foreground">We verify availability</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<BellIcon className="h-5 w-5 text-primary" />
|
||||
<span className="text-xs font-medium text-foreground">Get notified</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Auth Section */}
|
||||
<InlineAuthSection
|
||||
title="Create your account"
|
||||
description="We'll verify internet availability at your address and notify you when ready."
|
||||
redirectTo={redirectTo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -127,7 +127,7 @@ export function PublicLandingView() {
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/link-whmcs"
|
||||
href="/auth/migrate"
|
||||
className="inline-flex items-center justify-center rounded-xl px-6 py-3 text-sm font-medium border border-border bg-card hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
Migrate account
|
||||
|
||||
@ -72,7 +72,7 @@ export function PublicLandingView() {
|
||||
Login to Portal
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/link-whmcs"
|
||||
href="/auth/migrate"
|
||||
className="block border-2 border-gray-200 text-gray-700 px-6 py-3 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200"
|
||||
>
|
||||
Migrate Account
|
||||
|
||||
@ -22,7 +22,7 @@ import { logger } from "@/lib/logger";
|
||||
const AUTH_ENDPOINTS = [
|
||||
"/api/auth/login",
|
||||
"/api/auth/signup",
|
||||
"/api/auth/link-whmcs",
|
||||
"/api/auth/migrate",
|
||||
"/api/auth/set-password",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/check-password-needed",
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
|
||||
2. **ID Verification Integrated** - Upload functionality is now built into the Profile page (`/account/settings`) rather than requiring a separate page.
|
||||
|
||||
3. **Opportunity Lifecycle Fields Exist** - `Opportunity_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are in place and working.
|
||||
3. **Opportunity Lifecycle Fields Exist** - `Portal_Source__c` (with portal picklist values) and `WHMCS_Service_ID__c` are in place and working.
|
||||
|
||||
4. **SIM vs Internet Flows Have Different Requirements** - SIM requires ID verification but not eligibility; Internet requires eligibility but not ID verification.
|
||||
|
||||
@ -68,7 +68,7 @@
|
||||
├─────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ CatalogService │ CheckoutService │ VerificationService │ OrderService │
|
||||
│ │ │ │ │
|
||||
│ OpportunityMatchingService │ OrderOrchestrator │ FulfillmentOrchestrator │
|
||||
│ Opportunity Resolution │ OrderOrchestrator │ FulfillmentOrchestrator │
|
||||
└───────┬──────────────────────┴─────────┬──────────┴────────────┬───────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
@ -364,7 +364,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
|
||||
4. **Manual Agent Work Required** - Agent checks NTT serviceability
|
||||
5. Agent updates Account with eligibility result
|
||||
6. Salesforce Flow sends email notification to customer
|
||||
7. **Note:** Opportunity is NOT created during eligibility - only at order placement
|
||||
7. **Note:** Opportunity **is created/reused during eligibility** (Stage = Introduction) so the Case can link to it (`Case.OpportunityId`) and the journey can be reused at order placement.
|
||||
|
||||
**Account Fields Updated:**
|
||||
| Field | Value Set | When |
|
||||
@ -425,14 +425,18 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
|
||||
|
||||
### Opportunity Management Module
|
||||
|
||||
**Location:** `apps/bff/src/modules/orders/services/opportunity-matching.service.ts`
|
||||
**Location (current code paths):**
|
||||
|
||||
- `apps/bff/src/modules/catalog/services/internet-catalog.service.ts` (eligibility request creates/reuses Opportunity + Case)
|
||||
- `apps/bff/src/modules/orders/services/order-orchestrator.service.ts` (order placement resolves Opportunity)
|
||||
- `apps/bff/src/integrations/salesforce/services/salesforce-opportunity.service.ts` (Salesforce query/create/update)
|
||||
|
||||
**Matching Rules:**
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| Order has `opportunityId` | Use it directly |
|
||||
| Internet order without Opp | Find Introduction/Ready stage or create new |
|
||||
| SIM order without Opp | Find open Opportunity or create new |
|
||||
| SIM order without Opp | Find Introduction/Ready stage or create new |
|
||||
| VPN order | Always create new Opportunity |
|
||||
|
||||
**Stage Transitions by Trigger:**
|
||||
@ -539,7 +543,7 @@ NOTE: Introduction/Ready stages may be used by agents for pre-order tracking,
|
||||
|
||||
- Salesforce Flow automatically sends email to customer when eligibility fields are updated
|
||||
- Portal polls Account for eligibility changes (customer sees result on next visit)
|
||||
- **Opportunity is created later at order placement** (not during eligibility check)
|
||||
- Opportunity is created/reused at eligibility request (Stage = Introduction) and then reused at order placement when possible
|
||||
|
||||
---
|
||||
|
||||
@ -662,7 +666,7 @@ LONG TERM:
|
||||
|
||||
**Status:** ✅ Confirmed fields exist:
|
||||
|
||||
- `Opportunity_Source__c` - Picklist with portal values
|
||||
- `Portal_Source__c` - Picklist with portal values
|
||||
- `WHMCS_Service_ID__c` - Number field for WHMCS linking
|
||||
|
||||
**Note:** Emails for eligibility and ID verification status changes are sent automatically from Salesforce (via Flow/Process Builder).
|
||||
|
||||
@ -21,7 +21,7 @@ This guide describes how eligibility and verification work in the customer porta
|
||||
|
||||
1. Customer navigates to `/account/shop/internet`
|
||||
2. Customer enters service address and requests eligibility check
|
||||
3. Portal creates a Salesforce Case for agent review
|
||||
3. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review
|
||||
4. Agent performs NTT serviceability check (manual process)
|
||||
5. Agent updates Account eligibility fields
|
||||
6. Salesforce Flow sends email notification to customer
|
||||
|
||||
@ -352,10 +352,10 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
||||
│ │
|
||||
│ 4. BIDIRECTIONAL LINK COMPLETE │
|
||||
│ └─ Opportunity.WHMCS_Service_ID__c → WHMCS Service │
|
||||
│ └─ WHMCS Service.OpportunityId → Opportunity │
|
||||
│ └─ WHMCS Service.OpportunityId → Opportunity (optional; helpful for ops/debugging) │
|
||||
│ │
|
||||
│ 5. CUSTOMER SEES SERVICE PAGE │
|
||||
│ └─ Portal queries WHMCS → gets OpportunityId → queries Opp │
|
||||
│ └─ Portal shows active services from WHMCS; lifecycle/cancellation tracking lives on the linked Salesforce Opportunity (via WHMCS_Service_ID__c) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@ -448,15 +448,15 @@ On/After 25th → Must select NEXT month or later
|
||||
- [x] Case.OpportunityId (standard lookup)
|
||||
- [x] Order.OpportunityId (standard lookup)
|
||||
|
||||
**New Fields to Create on Opportunity:**
|
||||
**Opportunity Fields Required (Portal writes these):**
|
||||
|
||||
- [ ] `Portal_Source__c` picklist
|
||||
- [ ] `WHMCS_Service_ID__c` number field
|
||||
- [ ] `Portal_Source__c` picklist (used to track how the Opportunity was created)
|
||||
- [ ] `WHMCS_Service_ID__c` number field (used to link WHMCS service → Salesforce Opportunity for cancellations)
|
||||
|
||||
### WHMCS Admin Tasks
|
||||
|
||||
- [ ] Create `OpportunityId` custom field on Services/Hosting
|
||||
- [ ] Document custom field ID for AddOrder API
|
||||
- [ ] Create an `OpportunityId` custom field on Services/Hosting (optional but recommended for ops/debugging)
|
||||
- [ ] Confirm whether your WHMCS expects `customfields[]` keys by **name** (`OpportunityId`) or by **numeric field id**, and configure accordingly
|
||||
|
||||
### BFF Development Tasks
|
||||
|
||||
@ -466,22 +466,20 @@ On/After 25th → Must select NEXT month or later
|
||||
- [x] Field map configuration
|
||||
- [x] `SalesforceOpportunityService`
|
||||
- [x] Cancellation deadline helpers (25th rule)
|
||||
- [x] Eligibility request creates/reuses Opportunity (Stage `Introduction`) and links Case (`Case.OpportunityId`)
|
||||
- [x] Order placement reuses Opportunity in `Introduction`/`Ready` (otherwise creates new `Post Processing`)
|
||||
- [x] Fulfillment passes `OpportunityId` to WHMCS and links `WHMCS_Service_ID__c` back to the Opportunity
|
||||
- [x] Cancellation Case creation + Opportunity cancellation field updates (Internet cancellations)
|
||||
|
||||
**Pending:**
|
||||
**Optional / Future:**
|
||||
|
||||
- [ ] 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
|
||||
- [ ] Read `OpportunityId` back from WHMCS service custom fields (portal currently relies on Salesforce `WHMCS_Service_ID__c` for linking)
|
||||
|
||||
### Frontend Tasks
|
||||
|
||||
- [ ] Order tracking page (from Salesforce Order)
|
||||
- [ ] Service page with cancellation status
|
||||
- [ ] Cancellation form with 25th deadline logic
|
||||
- [x] Cancellation forms (Internet + SIM) with 25th deadline logic
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -201,12 +201,16 @@ export const OPPORTUNITY_SOURCE = {
|
||||
export type OpportunitySourceValue = (typeof OPPORTUNITY_SOURCE)[keyof typeof OPPORTUNITY_SOURCE];
|
||||
|
||||
// ============================================================================
|
||||
// Opportunity Matching Constants
|
||||
// Opportunity Matching / Stage Sets
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Stages considered "open" for matching purposes
|
||||
* Opportunities in these stages can be linked to new orders
|
||||
* Stages considered "open" (i.e. not closed) in our lifecycle.
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - Do NOT use this list to match a *new* order or eligibility request to an existing Opportunity.
|
||||
* For those flows we intentionally match a much smaller set of early-stage Opportunities.
|
||||
* - Use the `OPPORTUNITY_MATCH_STAGES_*` constants below instead.
|
||||
*/
|
||||
export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
|
||||
OPPORTUNITY_STAGE.INTRODUCTION,
|
||||
@ -215,6 +219,27 @@ export const OPEN_OPPORTUNITY_STAGES: OpportunityStageValue[] = [
|
||||
OPPORTUNITY_STAGE.ACTIVE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Stages eligible for matching during an Internet eligibility request.
|
||||
*
|
||||
* We only ever reuse the initial "Introduction" opportunity; later stages indicate the
|
||||
* customer journey has progressed beyond eligibility.
|
||||
*/
|
||||
export const OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY: OpportunityStageValue[] = [
|
||||
OPPORTUNITY_STAGE.INTRODUCTION,
|
||||
];
|
||||
|
||||
/**
|
||||
* Stages eligible for matching during order placement.
|
||||
*
|
||||
* If a customer came from eligibility, the Opportunity will usually be in "Introduction" or "Ready".
|
||||
* We must never match an "Active" Opportunity for a new order (that would corrupt lifecycle tracking).
|
||||
*/
|
||||
export const OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT: OpportunityStageValue[] = [
|
||||
OPPORTUNITY_STAGE.INTRODUCTION,
|
||||
OPPORTUNITY_STAGE.READY,
|
||||
];
|
||||
|
||||
/**
|
||||
* Stages that indicate the Opportunity is closed
|
||||
*/
|
||||
|
||||
@ -45,6 +45,8 @@ export {
|
||||
type OpportunitySourceValue,
|
||||
// Matching constants
|
||||
OPEN_OPPORTUNITY_STAGES,
|
||||
OPPORTUNITY_MATCH_STAGES_INTERNET_ELIGIBILITY,
|
||||
OPPORTUNITY_MATCH_STAGES_ORDER_PLACEMENT,
|
||||
CLOSED_OPPORTUNITY_STAGES,
|
||||
// Deadline constants
|
||||
CANCELLATION_DEADLINE_DAY,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user