From 12943752050eaf3783a6a830e27ff02f848b7dea Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 15 Jan 2026 17:33:23 +0900 Subject: [PATCH] feat: implement InlineGetStartedSection for email-first registration flow on service pages --- .../opportunity-resolution.service.ts | 11 +- .../services/order-orchestrator.service.ts | 93 ++++++++- .../internet-eligibility.service.ts | 8 +- apps/portal/src/components/atoms/button.tsx | 6 +- .../steps/AccountStatusStep.tsx | 13 +- .../steps/CompleteAccountStep.tsx | 143 ++++++++----- .../InlineGetStartedSection.tsx | 196 ++++++++++++++++++ .../InlineGetStartedSection/index.ts | 1 + .../features/get-started/components/index.ts | 1 + apps/portal/src/features/get-started/index.ts | 3 +- .../get-started/stores/get-started.store.ts | 19 ++ .../landing-page/views/PublicLandingView.tsx | 2 +- .../eligibility-check/steps/SuccessStep.tsx | 3 - .../services/views/PublicSimConfigure.tsx | 7 +- 14 files changed, 415 insertions(+), 91 deletions(-) create mode 100644 apps/portal/src/features/get-started/components/InlineGetStartedSection/InlineGetStartedSection.tsx create mode 100644 apps/portal/src/features/get-started/components/InlineGetStartedSection/index.ts diff --git a/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts index 780de186..c828f92f 100644 --- a/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts +++ b/apps/bff/src/integrations/salesforce/services/opportunity-resolution.service.ts @@ -83,13 +83,14 @@ export class OpportunityResolutionService { accountId: string | null; orderType: OrderTypeValue; existingOpportunityId?: string | undefined; - }): Promise { - if (!params.accountId) return null; + }): Promise<{ opportunityId: string | null; wasCreated: boolean }> { + if (!params.accountId) return { opportunityId: null, wasCreated: false }; const safeAccountId = assertSalesforceId(params.accountId, "accountId"); if (params.existingOpportunityId) { - return assertSalesforceId(params.existingOpportunityId, "existingOpportunityId"); + const validated = assertSalesforceId(params.existingOpportunityId, "existingOpportunityId"); + return { opportunityId: validated, wasCreated: false }; } const productType = this.mapOrderTypeToProductType(params.orderType); @@ -105,7 +106,7 @@ export class OpportunityResolutionService { ); if (existing) { - return existing; + return { opportunityId: existing, wasCreated: false }; } const created = await this.opportunities.createOpportunity({ @@ -123,7 +124,7 @@ export class OpportunityResolutionService { orderType: params.orderType, }); - return created; + return { opportunityId: created, wasCreated: true }; }, { ttlMs: 10_000 } ); diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index d8a3f6ac..803a76f1 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -3,6 +3,8 @@ 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 { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js"; +import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; +import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { OrderValidator } from "./order-validator.service.js"; import { OrderBuilder } from "./order-builder.service.js"; import { OrderItemBuilder } from "./order-item-builder.service.js"; @@ -16,6 +18,8 @@ import type { } from "@customer-portal/domain/orders"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util.js"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; +import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; type OrderDetailsResponse = OrderDetails; type OrderSummaryResponse = OrderSummary; @@ -31,6 +35,8 @@ export class OrderOrchestrator { private readonly salesforceOrderService: SalesforceOrderService, private readonly opportunityService: SalesforceOpportunityService, private readonly opportunityResolution: OpportunityResolutionService, + private readonly caseService: SalesforceCaseService, + private readonly sfConnection: SalesforceConnection, private readonly orderValidator: OrderValidator, private readonly orderBuilder: OrderBuilder, private readonly orderItemBuilder: OrderItemBuilder, @@ -57,7 +63,7 @@ export class OrderOrchestrator { ); // 2) Resolve Opportunity for this order - const opportunityId = await this.resolveOpportunityForOrder( + const { opportunityId, wasCreated: opportunityCreated } = await this.resolveOpportunityForOrder( validatedBody.orderType, userMapping.sfAccountId ?? null, validatedBody.opportunityId @@ -113,6 +119,17 @@ export class OrderOrchestrator { } } + // 5) Create internal "Order Placed" case for CS team + if (userMapping.sfAccountId) { + await this.createOrderPlacedCase({ + accountId: userMapping.sfAccountId, + orderId: created.id, + orderType: validatedBody.orderType, + opportunityId, + opportunityCreated, + }); + } + if (userMapping.sfAccountId) { await this.ordersCache.invalidateAccountOrders(userMapping.sfAccountId); } @@ -124,6 +141,7 @@ export class OrderOrchestrator { skuCount: validatedBody.skus.length, orderItemCount: orderItemsPayload.length, opportunityId, + opportunityCreated, }, "Order creation workflow completed successfully" ); @@ -135,6 +153,66 @@ export class OrderOrchestrator { }; } + /** + * Create an internal case when an order is placed. + * This is for CS team visibility - not visible to customers. + */ + private async createOrderPlacedCase(params: { + accountId: string; + orderId: string; + orderType: OrderTypeValue; + opportunityId: string | null; + opportunityCreated: boolean; + }): Promise { + try { + const instanceUrl = this.sfConnection.getInstanceUrl(); + const orderLink = instanceUrl + ? `${instanceUrl}/lightning/r/Order/${params.orderId}/view` + : null; + const opportunityLink = + params.opportunityId && instanceUrl + ? `${instanceUrl}/lightning/r/Opportunity/${params.opportunityId}/view` + : null; + + const opportunityStatus = params.opportunityId + ? params.opportunityCreated + ? "Created new opportunity for this order" + : "Linked to existing opportunity" + : "No opportunity linked"; + + const descriptionLines = [ + "Order placed via Customer Portal.", + "", + `Order ID: ${params.orderId}`, + orderLink ? `Order: ${orderLink}` : null, + "", + params.opportunityId ? `Opportunity ID: ${params.opportunityId}` : null, + opportunityLink ? `Opportunity: ${opportunityLink}` : null, + `Opportunity Status: ${opportunityStatus}`, + ].filter(Boolean); + + await this.caseService.createCase({ + accountId: params.accountId, + opportunityId: params.opportunityId ?? undefined, + subject: `Order Placed - ${params.orderType} (Portal)`, + description: descriptionLines.join("\n"), + origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION, + }); + + this.logger.log("Created Order Placed case", { + orderId: params.orderId, + opportunityIdTail: params.opportunityId?.slice(-4), + opportunityCreated: params.opportunityCreated, + }); + } catch (error) { + // Log but don't fail the order + this.logger.warn("Failed to create Order Placed case", { + orderId: params.orderId, + error: extractErrorMessage(error), + }); + } + } + /** * Resolve Opportunity for an order * @@ -146,26 +224,27 @@ export class OrderOrchestrator { orderType: OrderTypeValue, sfAccountId: string | null, existingOpportunityId?: string - ): Promise { + ): Promise<{ opportunityId: string | null; wasCreated: boolean }> { try { - const resolved = await this.opportunityResolution.resolveForOrderPlacement({ + const result = await this.opportunityResolution.resolveForOrderPlacement({ accountId: sfAccountId, orderType, existingOpportunityId, }); - if (resolved) { + if (result.opportunityId) { this.logger.debug("Resolved Opportunity for order", { - opportunityIdTail: resolved.slice(-4), + opportunityIdTail: result.opportunityId.slice(-4), + wasCreated: result.wasCreated, orderType, }); } - return resolved; + return result; } catch { const accountIdTail = typeof sfAccountId === "string" && sfAccountId.length >= 4 ? sfAccountId.slice(-4) : "none"; this.logger.warn("Failed to resolve Opportunity for order", { orderType, accountIdTail }); // Don't fail the order if Opportunity resolution fails - return null; + return { opportunityId: null, wasCreated: false }; } } diff --git a/apps/bff/src/modules/services/application/internet-eligibility.service.ts b/apps/bff/src/modules/services/application/internet-eligibility.service.ts index cb4211eb..802a1438 100644 --- a/apps/bff/src/modules/services/application/internet-eligibility.service.ts +++ b/apps/bff/src/modules/services/application/internet-eligibility.service.ts @@ -125,11 +125,17 @@ export class InternetEligibilityService { ? `${instanceUrl}/lightning/r/Opportunity/${opportunityId}/view` : null; + const opportunityStatus = opportunityCreated + ? "Created new opportunity for this request" + : "Linked to existing opportunity"; + const descriptionLines: string[] = [ "Customer requested to check if internet service is available at the following address:", "", request.address ? formatAddressForLog(request.address) : "", - opportunityLink ? `\n\nOpportunity: ${opportunityLink}` : "", + "", + opportunityLink ? `Opportunity: ${opportunityLink}` : "", + `Opportunity Status: ${opportunityStatus}`, ].filter(Boolean); // 3) Create Case linked to Opportunity (internal workflow case) diff --git a/apps/portal/src/components/atoms/button.tsx b/apps/portal/src/components/atoms/button.tsx index bbba8d07..99669e04 100644 --- a/apps/portal/src/components/atoms/button.tsx +++ b/apps/portal/src/components/atoms/button.tsx @@ -87,7 +87,7 @@ const Button = forwardRef((p > {loading ? : leftIcon} - {loading ? (loadingText ?? children) : children} + {loading ? (loadingText ?? children) : children} {!loading && rightIcon ? ( {rightIcon} @@ -108,7 +108,7 @@ const Button = forwardRef((p > {loading ? : leftIcon} - {loading ? (loadingText ?? children) : children} + {loading ? (loadingText ?? children) : children} {!loading && rightIcon ? ( {rightIcon} @@ -138,7 +138,7 @@ const Button = forwardRef((p > {loading ? : leftIcon} - {loading ? (loadingText ?? children) : children} + {loading ? (loadingText ?? children) : children} {!loading && rightIcon ? ( {rightIcon} diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx index fcd43663..794db36c 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx @@ -17,7 +17,6 @@ import { UserCircleIcon, ArrowRightIcon, DocumentCheckIcon, - UserPlusIcon, } from "@heroicons/react/24/outline"; import { CheckCircle2 } from "lucide-react"; import { useGetStartedStore } from "../../../stores/get-started.store"; @@ -170,17 +169,9 @@ export function AccountStatusStep() { - -

- Want to check service availability first?{" "} - - Check eligibility - {" "} - without creating an account. -

); } diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx index 39f27458..70c9d553 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx @@ -181,17 +181,48 @@ export function CompleteAccountStep() {

- {/* Pre-filled info display (SF-only users) */} + {/* Pre-filled info display in gray disabled inputs (SF-only users) */} {hasPrefill && ( -
-

Account for:

-

- {prefill?.firstName} {prefill?.lastName} -

+
+
+
+ + +
+
+ + +
+
+
+ + +
{prefill?.address && ( -

- {prefill.address.city}, {prefill.address.state} -

+
+ + +
)}
)} @@ -252,53 +283,6 @@ export function CompleteAccountStep() { )} - {/* Password */} -
- - { - setPassword(e.target.value); - setLocalErrors(prev => ({ ...prev, password: undefined })); - }} - placeholder="Create a strong password" - disabled={loading} - error={localErrors.password} - autoComplete="new-password" - /> - {localErrors.password &&

{localErrors.password}

} -

- At least 8 characters with uppercase, lowercase, and numbers -

-
- - {/* Confirm Password */} -
- - { - setConfirmPassword(e.target.value); - setLocalErrors(prev => ({ ...prev, confirmPassword: undefined })); - }} - placeholder="Confirm your password" - disabled={loading} - error={localErrors.confirmPassword} - autoComplete="new-password" - /> - {localErrors.confirmPassword && ( -

{localErrors.confirmPassword}

- )} -
- {/* Phone */}
+ {/* Password (at bottom before terms) */} +
+ + { + setPassword(e.target.value); + setLocalErrors(prev => ({ ...prev, password: undefined })); + }} + placeholder="Create a strong password" + disabled={loading} + error={localErrors.password} + autoComplete="new-password" + /> + {localErrors.password &&

{localErrors.password}

} +

+ At least 8 characters with uppercase, lowercase, and numbers +

+
+ + {/* Confirm Password */} +
+ + { + setConfirmPassword(e.target.value); + setLocalErrors(prev => ({ ...prev, confirmPassword: undefined })); + }} + placeholder="Confirm your password" + disabled={loading} + error={localErrors.confirmPassword} + autoComplete="new-password" + /> + {localErrors.confirmPassword && ( +

{localErrors.confirmPassword}

+ )} +
+ {/* Terms & Marketing */}
diff --git a/apps/portal/src/features/get-started/components/InlineGetStartedSection/InlineGetStartedSection.tsx b/apps/portal/src/features/get-started/components/InlineGetStartedSection/InlineGetStartedSection.tsx new file mode 100644 index 00000000..e6e7ec60 --- /dev/null +++ b/apps/portal/src/features/get-started/components/InlineGetStartedSection/InlineGetStartedSection.tsx @@ -0,0 +1,196 @@ +/** + * InlineGetStartedSection - Inline email-first registration for service pages + * + * Uses the get-started store flow (email → OTP → status → form) inline on service pages + * like the SIM configure page. Supports service context to track plan selection through the flow. + */ + +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/atoms"; +import { LoginForm } from "@/features/auth/components/LoginForm/LoginForm"; +import { LinkWhmcsForm } from "@/features/auth/components/LinkWhmcsForm/LinkWhmcsForm"; +import { getSafeRedirect } from "@/features/auth/utils/route-protection"; +import { useGetStartedStore, type ServiceContext } from "../../stores/get-started.store"; +import { EmailStep } from "../GetStartedForm/steps/EmailStep"; +import { VerificationStep } from "../GetStartedForm/steps/VerificationStep"; +import { AccountStatusStep } from "../GetStartedForm/steps/AccountStatusStep"; +import { CompleteAccountStep } from "../GetStartedForm/steps/CompleteAccountStep"; + +interface HighlightItem { + title: string; + description: string; +} + +interface InlineGetStartedSectionProps { + title: string; + description?: string; + serviceContext?: Omit; + redirectTo?: string; + highlights?: HighlightItem[]; + className?: string; +} + +export function InlineGetStartedSection({ + title, + description, + serviceContext, + redirectTo, + highlights = [], + className = "", +}: InlineGetStartedSectionProps) { + const router = useRouter(); + const [mode, setMode] = useState<"signup" | "login" | "migrate">("signup"); + const safeRedirect = getSafeRedirect(redirectTo, "/account"); + + const { step, reset, setServiceContext } = useGetStartedStore(); + + // Set service context when component mounts + useEffect(() => { + if (serviceContext) { + setServiceContext({ + ...serviceContext, + redirectTo: safeRedirect, + }); + } + return () => { + // Clear service context when unmounting + setServiceContext(null); + }; + }, [serviceContext, safeRedirect, setServiceContext]); + + // Reset get-started store when switching to signup mode + const handleModeChange = (newMode: "signup" | "login" | "migrate") => { + if (newMode === "signup" && mode !== "signup") { + reset(); + // Re-set service context after reset + if (serviceContext) { + setServiceContext({ + ...serviceContext, + redirectTo: safeRedirect, + }); + } + } + setMode(newMode); + }; + + // Render the current step for signup flow + const renderSignupStep = () => { + switch (step) { + case "email": + return ; + case "verification": + return ; + case "account-status": + return ; + case "complete-account": + return ; + case "success": + // Redirect on success + router.push(safeRedirect); + return null; + default: + return ; + } + }; + + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ +
+
+ + + +
+
+ +
+
+ {mode === "signup" && ( + <> +

Create your account

+

+ Verify your email to get started. +

+ {renderSignupStep()} + + )} + {mode === "login" && ( + <> +

Sign in

+

Access your account to continue.

+ + + )} + {mode === "migrate" && ( + <> +

Migrate your account

+

+ Use your legacy portal credentials to transfer your account. +

+ { + if (result.needsPasswordSet) { + const params = new URLSearchParams({ + email: result.user.email, + redirect: safeRedirect, + }); + router.push(`/auth/set-password?${params.toString()}`); + return; + } + router.push(safeRedirect); + }} + /> + + )} +
+
+ + {highlights.length > 0 && ( +
+
+ {highlights.map(item => ( +
+
{item.title}
+
{item.description}
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/portal/src/features/get-started/components/InlineGetStartedSection/index.ts b/apps/portal/src/features/get-started/components/InlineGetStartedSection/index.ts new file mode 100644 index 00000000..df191d79 --- /dev/null +++ b/apps/portal/src/features/get-started/components/InlineGetStartedSection/index.ts @@ -0,0 +1 @@ +export { InlineGetStartedSection } from "./InlineGetStartedSection"; diff --git a/apps/portal/src/features/get-started/components/index.ts b/apps/portal/src/features/get-started/components/index.ts index 37311086..e42486de 100644 --- a/apps/portal/src/features/get-started/components/index.ts +++ b/apps/portal/src/features/get-started/components/index.ts @@ -1,2 +1,3 @@ export { GetStartedForm } from "./GetStartedForm"; export { OtpInput } from "./OtpInput"; +export { InlineGetStartedSection } from "./InlineGetStartedSection"; diff --git a/apps/portal/src/features/get-started/index.ts b/apps/portal/src/features/get-started/index.ts index 8220e616..ac606d44 100644 --- a/apps/portal/src/features/get-started/index.ts +++ b/apps/portal/src/features/get-started/index.ts @@ -12,13 +12,14 @@ export { GetStartedView } from "./views"; // Components -export { GetStartedForm, OtpInput } from "./components"; +export { GetStartedForm, OtpInput, InlineGetStartedSection } from "./components"; // Store export { useGetStartedStore, type GetStartedStep, type GetStartedState, + type ServiceContext, } from "./stores/get-started.store"; // API diff --git a/apps/portal/src/features/get-started/stores/get-started.store.ts b/apps/portal/src/features/get-started/stores/get-started.store.ts index c3ed26f4..1d3ce162 100644 --- a/apps/portal/src/features/get-started/stores/get-started.store.ts +++ b/apps/portal/src/features/get-started/stores/get-started.store.ts @@ -19,6 +19,16 @@ export type GetStartedStep = | "complete-account" | "success"; +/** + * Service context for tracking which service flow the user came from + * (e.g., SIM plan selection) + */ +export interface ServiceContext { + type: "sim" | "internet" | null; + planSku?: string | undefined; + redirectTo?: string | undefined; +} + /** * Address data format used in the get-started form */ @@ -66,6 +76,9 @@ export interface GetStartedState { // Handoff token from eligibility check (for pre-filling data after OTP verification) handoffToken: string | null; + // Service context for tracking which service flow the user came from + serviceContext: ServiceContext | null; + // Loading and error states loading: boolean; error: string | null; @@ -91,6 +104,7 @@ export interface GetStartedState { setPrefill: (prefill: VerifyCodeResponse["prefill"]) => void; setSessionToken: (token: string | null) => void; setHandoffToken: (token: string | null) => void; + setServiceContext: (context: ServiceContext | null) => void; // Reset reset: () => void; @@ -119,6 +133,7 @@ const initialState = { formData: initialFormData, prefill: null, handoffToken: null, + serviceContext: null as ServiceContext | null, loading: false, error: null, codeSent: false, @@ -285,6 +300,10 @@ export const useGetStartedStore = create()((set, get) => ({ set({ handoffToken: token }); }, + setServiceContext: (context: ServiceContext | null) => { + set({ serviceContext: context }); + }, + reset: () => { set(initialState); }, diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 2c2396c9..c4359110 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -67,7 +67,7 @@ export function PublicLandingView() { style={{ animationDelay: "300ms" }} > Browse Services diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx index ee7f9703..22e3bbb2 100644 --- a/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx +++ b/apps/portal/src/features/services/components/eligibility-check/steps/SuccessStep.tsx @@ -118,9 +118,6 @@ export function SuccessStep() { > View Internet Plans -

- Create an account to track your request and order services faster. -

)} diff --git a/apps/portal/src/features/services/views/PublicSimConfigure.tsx b/apps/portal/src/features/services/views/PublicSimConfigure.tsx index 681f6090..d6b20f7b 100644 --- a/apps/portal/src/features/services/views/PublicSimConfigure.tsx +++ b/apps/portal/src/features/services/views/PublicSimConfigure.tsx @@ -6,7 +6,7 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { usePublicSimPlan } from "@/features/services/hooks"; -import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; +import { InlineGetStartedSection } from "@/features/get-started"; import { CardPricing } from "@/features/services/components/base/CardPricing"; import { Skeleton } from "@/components/atoms/loading-skeleton"; @@ -159,9 +159,10 @@ export function PublicSimConfigureView() {
{/* Auth Section */} -