feat: implement InlineGetStartedSection for email-first registration flow on service pages

This commit is contained in:
barsa 2026-01-15 17:33:23 +09:00
parent 7bcd7fa10d
commit 1294375205
14 changed files with 415 additions and 91 deletions

View File

@ -83,13 +83,14 @@ export class OpportunityResolutionService {
accountId: string | null;
orderType: OrderTypeValue;
existingOpportunityId?: string | undefined;
}): Promise<string | null> {
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 }
);

View File

@ -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<void> {
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<string | null> {
): 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 };
}
}

View File

@ -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)

View File

@ -87,7 +87,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
>
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span className="flex-1">{loading ? (loadingText ?? children) : children}</span>
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? (
<span className="transition-transform duration-200 group-hover:translate-x-0.5">
{rightIcon}
@ -108,7 +108,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
>
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span className="flex-1">{loading ? (loadingText ?? children) : children}</span>
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? (
<span className="transition-transform duration-200 group-hover:translate-x-0.5">
{rightIcon}
@ -138,7 +138,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
>
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span className="flex-1">{loading ? (loadingText ?? children) : children}</span>
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon ? (
<span className="transition-transform duration-200 group-hover:translate-x-0.5">
{rightIcon}

View File

@ -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() {
</div>
<Button onClick={() => goToStep("complete-account")} className="w-full h-11">
<UserPlusIcon className="h-4 w-4 mr-2" />
Create My Account
Continue
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
<p className="text-xs text-muted-foreground">
Want to check service availability first?{" "}
<Link href="/services/internet" className="text-primary hover:underline">
Check eligibility
</Link>{" "}
without creating an account.
</p>
</div>
);
}

View File

@ -181,17 +181,48 @@ export function CompleteAccountStep() {
</p>
</div>
{/* Pre-filled info display (SF-only users) */}
{/* Pre-filled info display in gray disabled inputs (SF-only users) */}
{hasPrefill && (
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-1">Account for:</p>
<p className="font-medium text-foreground">
{prefill?.firstName} {prefill?.lastName}
</p>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-muted-foreground">First Name</Label>
<Input
value={prefill?.firstName || ""}
disabled
className="bg-muted text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Last Name</Label>
<Input
value={prefill?.lastName || ""}
disabled
className="bg-muted text-muted-foreground"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Email</Label>
<Input value={formData.email} disabled className="bg-muted text-muted-foreground" />
</div>
{prefill?.address && (
<p className="text-sm text-muted-foreground mt-1">
{prefill.address.city}, {prefill.address.state}
</p>
<div className="space-y-2">
<Label className="text-muted-foreground">Address</Label>
<Input
value={[
prefill.address.postcode,
prefill.address.state,
prefill.address.city,
prefill.address.address1,
prefill.address.address2,
]
.filter(Boolean)
.join(", ")}
disabled
className="bg-muted text-muted-foreground"
/>
</div>
)}
</div>
)}
@ -252,53 +283,6 @@ export function CompleteAccountStep() {
</>
)}
{/* Password */}
<div className="space-y-2">
<Label htmlFor="password">
Password <span className="text-danger">*</span>
</Label>
<Input
id="password"
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
setLocalErrors(prev => ({ ...prev, password: undefined }));
}}
placeholder="Create a strong password"
disabled={loading}
error={localErrors.password}
autoComplete="new-password"
/>
{localErrors.password && <p className="text-sm text-danger">{localErrors.password}</p>}
<p className="text-xs text-muted-foreground">
At least 8 characters with uppercase, lowercase, and numbers
</p>
</div>
{/* Confirm Password */}
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirm Password <span className="text-danger">*</span>
</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value);
setLocalErrors(prev => ({ ...prev, confirmPassword: undefined }));
}}
placeholder="Confirm your password"
disabled={loading}
error={localErrors.confirmPassword}
autoComplete="new-password"
/>
{localErrors.confirmPassword && (
<p className="text-sm text-danger">{localErrors.confirmPassword}</p>
)}
</div>
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="phone">
@ -368,6 +352,53 @@ export function CompleteAccountStep() {
{localErrors.gender && <p className="text-sm text-danger">{localErrors.gender}</p>}
</div>
{/* Password (at bottom before terms) */}
<div className="space-y-2">
<Label htmlFor="password">
Password <span className="text-danger">*</span>
</Label>
<Input
id="password"
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
setLocalErrors(prev => ({ ...prev, password: undefined }));
}}
placeholder="Create a strong password"
disabled={loading}
error={localErrors.password}
autoComplete="new-password"
/>
{localErrors.password && <p className="text-sm text-danger">{localErrors.password}</p>}
<p className="text-xs text-muted-foreground">
At least 8 characters with uppercase, lowercase, and numbers
</p>
</div>
{/* Confirm Password */}
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirm Password <span className="text-danger">*</span>
</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value);
setLocalErrors(prev => ({ ...prev, confirmPassword: undefined }));
}}
placeholder="Confirm your password"
disabled={loading}
error={localErrors.confirmPassword}
autoComplete="new-password"
/>
{localErrors.confirmPassword && (
<p className="text-sm text-danger">{localErrors.confirmPassword}</p>
)}
</div>
{/* Terms & Marketing */}
<div className="space-y-3">
<div className="flex items-start gap-2">

View File

@ -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<ServiceContext, "redirectTo">;
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 <EmailStep />;
case "verification":
return <VerificationStep />;
case "account-status":
return <AccountStatusStep />;
case "complete-account":
return <CompleteAccountStep />;
case "success":
// Redirect on success
router.push(safeRedirect);
return null;
default:
return <EmailStep />;
}
};
return (
<div className={`bg-muted/50 border border-border rounded-2xl p-6 md:p-8 ${className}`}>
<div className="text-center mb-6">
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground max-w-2xl mx-auto">{description}</p>
)}
</div>
<div className="flex justify-center">
<div className="inline-flex flex-wrap justify-center rounded-full border border-border bg-background p-1 shadow-[var(--cp-shadow-1)] gap-1">
<Button
type="button"
size="sm"
variant={mode === "signup" ? "default" : "ghost"}
onClick={() => handleModeChange("signup")}
className="rounded-full"
>
Create account
</Button>
<Button
type="button"
size="sm"
variant={mode === "login" ? "default" : "ghost"}
onClick={() => handleModeChange("login")}
className="rounded-full"
>
Sign in
</Button>
<Button
type="button"
size="sm"
variant={mode === "migrate" ? "default" : "ghost"}
onClick={() => handleModeChange("migrate")}
className="rounded-full"
>
Migrate
</Button>
</div>
</div>
<div className="mt-6">
<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">
Verify your email to get started.
</p>
{renderSignupStep()}
</>
)}
{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 && (
<div className="mt-6 pt-6 border-t border-border">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
{highlights.map(item => (
<div key={item.title}>
<div className="text-sm font-medium text-foreground mb-1">{item.title}</div>
<div className="text-xs text-muted-foreground">{item.description}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { InlineGetStartedSection } from "./InlineGetStartedSection";

View File

@ -1,2 +1,3 @@
export { GetStartedForm } from "./GetStartedForm";
export { OtpInput } from "./OtpInput";
export { InlineGetStartedSection } from "./InlineGetStartedSection";

View File

@ -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

View File

@ -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<GetStartedState>()((set, get) => ({
set({ handoffToken: token });
},
setServiceContext: (context: ServiceContext | null) => {
set({ serviceContext: context });
},
reset: () => {
set(initialState);
},

View File

@ -67,7 +67,7 @@ export function PublicLandingView() {
style={{ animationDelay: "300ms" }}
>
<Link
href="#services"
href="/services"
className="group inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-3.5 font-semibold text-primary-foreground shadow-sm hover:bg-primary-hover hover:shadow-md transition-all"
>
Browse Services

View File

@ -118,9 +118,6 @@ export function SuccessStep() {
>
View Internet Plans
</Button>
<p className="text-xs text-muted-foreground text-center">
Create an account to track your request and order services faster.
</p>
</div>
)}

View File

@ -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() {
</div>
{/* Auth Section */}
<InlineAuthSection
<InlineGetStartedSection
title="Create your account to order"
description="Quick signup to configure your SIM and complete checkout."
description="Verify your email to get started with your SIM order."
serviceContext={{ type: "sim", planSku: planSku || undefined }}
redirectTo={redirectTarget}
/>
</div>