feat: implement InlineGetStartedSection for email-first registration flow on service pages
This commit is contained in:
parent
7bcd7fa10d
commit
1294375205
@ -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 }
|
||||
);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { InlineGetStartedSection } from "./InlineGetStartedSection";
|
||||
@ -1,2 +1,3 @@
|
||||
export { GetStartedForm } from "./GetStartedForm";
|
||||
export { OtpInput } from "./OtpInput";
|
||||
export { InlineGetStartedSection } from "./InlineGetStartedSection";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user