feat: Implement unified eligibility check flow with inline OTP verification
- Refactor PublicLandingView to enhance service section animations. - Update SimPlansContent and PublicEligibilityCheck to streamline service highlights. - Revise PublicEligibilityCheck to support new flow: "Send Request Only" and "Continue to Create Account". - Introduce guest eligibility check API with handoff token for account creation. - Modify success step to provide clear options for account creation and navigation. - Enhance form handling and error management in PublicEligibilityCheckView. - Update domain schemas to accommodate guest eligibility requests and responses. - Document new eligibility check flows and testing procedures.
This commit is contained in:
parent
bb4be98444
commit
1d1602f5e7
@ -4,7 +4,11 @@ import { Injectable, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
import type { GetStartedSession, AccountStatus } from "@customer-portal/domain/get-started";
|
||||
import type {
|
||||
GetStartedSession,
|
||||
AccountStatus,
|
||||
GuestHandoffToken,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
|
||||
import { CacheService } from "@/infra/cache/cache.service.js";
|
||||
|
||||
@ -31,7 +35,9 @@ interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
|
||||
@Injectable()
|
||||
export class GetStartedSessionService {
|
||||
private readonly SESSION_PREFIX = "get-started-session:";
|
||||
private readonly HANDOFF_PREFIX = "guest-handoff:";
|
||||
private readonly ttlSeconds: number;
|
||||
private readonly handoffTtlSeconds = 1800; // 30 minutes for handoff tokens
|
||||
|
||||
constructor(
|
||||
private readonly cache: CacheService,
|
||||
@ -193,10 +199,92 @@ export class GetStartedSessionService {
|
||||
return session;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Guest Handoff Token Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a guest handoff token for eligibility-to-account-creation flow
|
||||
*
|
||||
* This token allows passing data from guest eligibility check to account creation
|
||||
* without requiring email verification first. The email will be verified when
|
||||
* the user proceeds to account creation.
|
||||
*
|
||||
* @param email - Email address (NOT verified)
|
||||
* @param data - User data from eligibility check
|
||||
* @returns Handoff token ID
|
||||
*/
|
||||
async createGuestHandoffToken(
|
||||
email: string,
|
||||
data: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
address: GetStartedSession["address"];
|
||||
phone?: string;
|
||||
sfAccountId: string;
|
||||
}
|
||||
): Promise<string> {
|
||||
const tokenId = randomUUID();
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
const tokenData: GuestHandoffToken = {
|
||||
id: tokenId,
|
||||
type: "guest_handoff",
|
||||
email: normalizedEmail,
|
||||
emailVerified: false,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
address: data.address,
|
||||
phone: data.phone,
|
||||
sfAccountId: data.sfAccountId,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.cache.set(this.buildHandoffKey(tokenId), tokenData, this.handoffTtlSeconds);
|
||||
|
||||
this.logger.debug({ email: normalizedEmail, tokenId }, "Guest handoff token created");
|
||||
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and retrieve guest handoff token data
|
||||
*
|
||||
* @param token - Handoff token ID
|
||||
* @returns Token data if valid, null otherwise
|
||||
*/
|
||||
async validateGuestHandoffToken(token: string): Promise<GuestHandoffToken | null> {
|
||||
const data = await this.cache.get<GuestHandoffToken>(this.buildHandoffKey(token));
|
||||
|
||||
if (!data) {
|
||||
this.logger.debug({ tokenId: token }, "Guest handoff token not found or expired");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data.type !== "guest_handoff") {
|
||||
this.logger.warn({ tokenId: token }, "Invalid handoff token type");
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate guest handoff token (after it's been used)
|
||||
*/
|
||||
async invalidateHandoffToken(token: string): Promise<void> {
|
||||
await this.cache.del(this.buildHandoffKey(token));
|
||||
this.logger.debug({ tokenId: token }, "Guest handoff token invalidated");
|
||||
}
|
||||
|
||||
private buildKey(sessionId: string): string {
|
||||
return `${this.SESSION_PREFIX}${sessionId}`;
|
||||
}
|
||||
|
||||
private buildHandoffKey(tokenId: string): string {
|
||||
return `${this.HANDOFF_PREFIX}${tokenId}`;
|
||||
}
|
||||
|
||||
private calculateExpiresAt(createdAt: string): string {
|
||||
const created = new Date(createdAt);
|
||||
const expires = new Date(created.getTime() + this.ttlSeconds * 1000);
|
||||
|
||||
@ -12,6 +12,8 @@ import {
|
||||
type VerifyCodeResponse,
|
||||
type QuickEligibilityRequest,
|
||||
type QuickEligibilityResponse,
|
||||
type GuestEligibilityRequest,
|
||||
type GuestEligibilityResponse,
|
||||
type CompleteAccountRequest,
|
||||
type MaybeLaterRequest,
|
||||
type MaybeLaterResponse,
|
||||
@ -273,6 +275,104 @@ export class GetStartedWorkflowService {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Guest Eligibility Check (No OTP Required)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Guest eligibility check - NO email verification required
|
||||
*
|
||||
* Allows users to check availability without verifying email first.
|
||||
* Creates SF Account + eligibility case immediately.
|
||||
* Email verification happens later when user creates an account.
|
||||
*
|
||||
* @param request - Guest eligibility request with name, email, address
|
||||
* @param fingerprint - Request fingerprint for logging/abuse detection
|
||||
*/
|
||||
async guestEligibilityCheck(
|
||||
request: GuestEligibilityRequest,
|
||||
fingerprint?: string
|
||||
): Promise<GuestEligibilityResponse> {
|
||||
const { email, firstName, lastName, address, phone, continueToAccount } = request;
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
this.logger.log(
|
||||
{ email: normalizedEmail, continueToAccount, fingerprint },
|
||||
"Guest eligibility check initiated"
|
||||
);
|
||||
|
||||
try {
|
||||
// Check if SF account already exists for this email
|
||||
let sfAccountId: string;
|
||||
|
||||
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
|
||||
|
||||
if (existingSf) {
|
||||
sfAccountId = existingSf.id;
|
||||
this.logger.log(
|
||||
{ email: normalizedEmail, sfAccountId },
|
||||
"Using existing SF account for guest eligibility check"
|
||||
);
|
||||
} else {
|
||||
// Create new SF Account (email NOT verified)
|
||||
const { accountId } = await this.salesforceAccountService.createAccount({
|
||||
firstName,
|
||||
lastName,
|
||||
email: normalizedEmail,
|
||||
phone: phone ?? "",
|
||||
});
|
||||
sfAccountId = accountId;
|
||||
this.logger.log(
|
||||
{ email: normalizedEmail, sfAccountId },
|
||||
"Created SF account for guest eligibility check"
|
||||
);
|
||||
}
|
||||
|
||||
// Create eligibility case
|
||||
const requestId = await this.createEligibilityCase(sfAccountId, address);
|
||||
|
||||
// Update Account eligibility status to Pending
|
||||
this.updateAccountEligibilityStatus(sfAccountId);
|
||||
|
||||
// If user wants to continue to account creation, generate a handoff token
|
||||
let handoffToken: string | undefined;
|
||||
if (continueToAccount) {
|
||||
handoffToken = await this.sessionService.createGuestHandoffToken(normalizedEmail, {
|
||||
firstName,
|
||||
lastName,
|
||||
address,
|
||||
phone,
|
||||
sfAccountId,
|
||||
});
|
||||
this.logger.debug(
|
||||
{ email: normalizedEmail, handoffToken },
|
||||
"Created handoff token for account creation"
|
||||
);
|
||||
}
|
||||
|
||||
// Send confirmation email
|
||||
await this.sendGuestEligibilityConfirmationEmail(normalizedEmail, firstName, requestId);
|
||||
|
||||
return {
|
||||
submitted: true,
|
||||
requestId,
|
||||
sfAccountId,
|
||||
handoffToken,
|
||||
message: "Eligibility check submitted. We'll notify you of the results.",
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error: extractErrorMessage(error), email: normalizedEmail },
|
||||
"Guest eligibility check failed"
|
||||
);
|
||||
|
||||
return {
|
||||
submitted: false,
|
||||
message: "Failed to submit eligibility check. Please try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Account Completion (SF-Only Users)
|
||||
// ============================================================================
|
||||
@ -458,6 +558,41 @@ export class GetStartedWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private async sendGuestEligibilityConfirmationEmail(
|
||||
email: string,
|
||||
firstName: string,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const templateId = this.config.get<string>("EMAIL_TEMPLATE_ELIGIBILITY_SUBMITTED");
|
||||
|
||||
if (templateId) {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "We're checking internet availability at your address",
|
||||
templateId,
|
||||
dynamicTemplateData: {
|
||||
firstName,
|
||||
portalUrl: appBase,
|
||||
email,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.emailService.sendEmail({
|
||||
to: email,
|
||||
subject: "We're checking internet availability at your address",
|
||||
html: `
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>We received your request to check internet availability.</p>
|
||||
<p>We'll review this and email you the results within 1-2 business days.</p>
|
||||
<p>To create an account and view your request status, visit: <a href="${appBase}/auth/get-started?email=${encodeURIComponent(email)}">${appBase}/auth/get-started</a></p>
|
||||
<p>Reference ID: ${requestId}</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async determineAccountStatus(
|
||||
email: string
|
||||
): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> {
|
||||
|
||||
@ -13,6 +13,8 @@ import {
|
||||
verifyCodeResponseSchema,
|
||||
quickEligibilityRequestSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
guestEligibilityRequestSchema,
|
||||
guestEligibilityResponseSchema,
|
||||
completeAccountRequestSchema,
|
||||
maybeLaterRequestSchema,
|
||||
maybeLaterResponseSchema,
|
||||
@ -27,6 +29,8 @@ class VerifyCodeRequestDto extends createZodDto(verifyCodeRequestSchema) {}
|
||||
class VerifyCodeResponseDto extends createZodDto(verifyCodeResponseSchema) {}
|
||||
class QuickEligibilityRequestDto extends createZodDto(quickEligibilityRequestSchema) {}
|
||||
class QuickEligibilityResponseDto extends createZodDto(quickEligibilityResponseSchema) {}
|
||||
class GuestEligibilityRequestDto extends createZodDto(guestEligibilityRequestSchema) {}
|
||||
class GuestEligibilityResponseDto extends createZodDto(guestEligibilityResponseSchema) {}
|
||||
class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {}
|
||||
class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {}
|
||||
class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {}
|
||||
@ -111,6 +115,28 @@ export class GetStartedController {
|
||||
return this.workflow.quickEligibilityCheck(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest eligibility check - NO email verification required
|
||||
* Creates SF Account + eligibility case without OTP verification
|
||||
*
|
||||
* This allows users to check availability without verifying their email first.
|
||||
* Email verification happens later when they create an account.
|
||||
*
|
||||
* Rate limit: 3 per 15 minutes per IP (stricter due to no OTP protection)
|
||||
*/
|
||||
@Public()
|
||||
@Post("guest-eligibility")
|
||||
@HttpCode(200)
|
||||
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
||||
@RateLimit({ limit: 3, ttl: 900 })
|
||||
async guestEligibilityCheck(
|
||||
@Body() body: GuestEligibilityRequestDto,
|
||||
@Req() req: Request
|
||||
): Promise<GuestEligibilityResponseDto> {
|
||||
const fingerprint = getRequestFingerprint(req);
|
||||
return this.workflow.guestEligibilityCheck(body, fingerprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Maybe Later" flow
|
||||
* Creates SF Account + eligibility case, sends confirmation email
|
||||
|
||||
@ -63,11 +63,8 @@ export default function ServicesPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* All Services - Clean Grid */}
|
||||
<section
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
{/* All Services - Clean Grid with staggered animations */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">
|
||||
<ServiceCard
|
||||
href="/services/internet"
|
||||
icon={<Wifi className="h-6 w-6" />}
|
||||
|
||||
@ -235,7 +235,11 @@ export function JapanAddressForm({
|
||||
const roomNumberOk =
|
||||
address.residenceType !== RESIDENCE_TYPE.APARTMENT || (address.roomNumber?.trim() ?? "") !== "";
|
||||
|
||||
const isComplete = isAddressVerified && hasResidenceType && baseFieldsFilled && roomNumberOk;
|
||||
// Building name is required for both houses and apartments
|
||||
const buildingNameOk = (address.buildingName?.trim() ?? "") !== "";
|
||||
|
||||
const isComplete =
|
||||
isAddressVerified && hasResidenceType && baseFieldsFilled && buildingNameOk && roomNumberOk;
|
||||
|
||||
// Notify parent of changes - only send valid typed address when residenceType is set
|
||||
useEffect(() => {
|
||||
@ -622,25 +626,24 @@ export function JapanAddressForm({
|
||||
{showSuccess ? <CheckCircle2 className="w-4 h-4" /> : "4"}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">Building Details</span>
|
||||
{!isApartment && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">Optional for houses</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Building Name */}
|
||||
<FormField
|
||||
label="Building Name"
|
||||
error={getError("buildingName")}
|
||||
required={false}
|
||||
required
|
||||
helperText={
|
||||
isApartment ? "e.g., Sunshine Mansion (サンシャインマンション)" : "Optional"
|
||||
isApartment
|
||||
? "e.g., Sunshine Mansion (サンシャインマンション)"
|
||||
: "e.g., Tanaka Residence (田中邸)"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={address.buildingName ?? ""}
|
||||
onChange={e => handleBuildingNameChange(e.target.value)}
|
||||
onBlur={() => onBlur?.("buildingName")}
|
||||
placeholder={isApartment ? "Sunshine Mansion" : "Building name (optional)"}
|
||||
placeholder={isApartment ? "Sunshine Mansion" : "Tanaka Residence"}
|
||||
disabled={disabled}
|
||||
data-field="address.buildingName"
|
||||
/>
|
||||
|
||||
@ -182,7 +182,6 @@ export function DashboardView() {
|
||||
{/* Bottom Section: Quick Stats + Recent Activity */}
|
||||
<section
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-6 cp-stagger-children"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
aria-label="Account overview"
|
||||
>
|
||||
<QuickStats
|
||||
@ -190,14 +189,11 @@ export function DashboardView() {
|
||||
openCases={summary?.stats?.openCases ?? 0}
|
||||
recentOrders={summary?.stats?.recentOrders}
|
||||
isLoading={summaryLoading}
|
||||
className="animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||
/>
|
||||
<ActivityFeed
|
||||
activities={summary?.recentActivity || []}
|
||||
maxItems={5}
|
||||
isLoading={summaryLoading}
|
||||
className="animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
/>
|
||||
</section>
|
||||
</PageLayout>
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
sendVerificationCodeResponseSchema,
|
||||
verifyCodeResponseSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
guestEligibilityResponseSchema,
|
||||
maybeLaterResponseSchema,
|
||||
type SendVerificationCodeRequest,
|
||||
type SendVerificationCodeResponse,
|
||||
@ -16,6 +17,8 @@ import {
|
||||
type VerifyCodeResponse,
|
||||
type QuickEligibilityRequest,
|
||||
type QuickEligibilityResponse,
|
||||
type GuestEligibilityRequest,
|
||||
type GuestEligibilityResponse,
|
||||
type CompleteAccountRequest,
|
||||
type MaybeLaterRequest,
|
||||
type MaybeLaterResponse,
|
||||
@ -49,7 +52,7 @@ export async function verifyCode(request: VerifyCodeRequest): Promise<VerifyCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick eligibility check (guest flow)
|
||||
* Quick eligibility check (guest flow) - requires OTP verification
|
||||
*/
|
||||
export async function quickEligibilityCheck(
|
||||
request: QuickEligibilityRequest
|
||||
@ -61,6 +64,22 @@ export async function quickEligibilityCheck(
|
||||
return quickEligibilityResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest eligibility check - NO OTP verification required
|
||||
* Allows users to check availability without verifying email first
|
||||
* Email verification happens later when user creates an account
|
||||
*/
|
||||
export async function guestEligibilityCheck(
|
||||
request: GuestEligibilityRequest
|
||||
): Promise<GuestEligibilityResponse> {
|
||||
const response = await apiClient.POST<GuestEligibilityResponse>(
|
||||
`${BASE_PATH}/guest-eligibility`,
|
||||
{ body: request }
|
||||
);
|
||||
const data = getDataOrThrow(response, "Failed to submit eligibility check");
|
||||
return guestEligibilityResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maybe later flow - create SF account and eligibility case
|
||||
*/
|
||||
|
||||
@ -55,9 +55,16 @@ interface GetStartedFormProps {
|
||||
export function GetStartedForm({ onStepChange }: GetStartedFormProps) {
|
||||
const { step, reset } = useGetStartedStore();
|
||||
|
||||
// Reset form on mount to ensure clean state
|
||||
// Reset form on mount to ensure clean state (but not if coming from handoff)
|
||||
useEffect(() => {
|
||||
reset();
|
||||
// Check if user is coming from eligibility check handoff
|
||||
const hasHandoffParam = window.location.search.includes("handoff=");
|
||||
const hasHandoffToken = sessionStorage.getItem("get-started-handoff-token");
|
||||
|
||||
// Don't reset if we have handoff data - let GetStartedView pre-fill the form
|
||||
if (!hasHandoffParam && !hasHandoffToken) {
|
||||
reset();
|
||||
}
|
||||
}, [reset]);
|
||||
|
||||
// Notify parent of step changes
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
DocumentCheckIcon,
|
||||
UserPlusIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useGetStartedStore } from "../../../stores/get-started.store";
|
||||
|
||||
export function AccountStatusStep() {
|
||||
@ -82,30 +83,70 @@ export function AccountStatusStep() {
|
||||
// SF exists but not mapped - complete account with pre-filled data
|
||||
if (accountStatus === "sf_unmapped") {
|
||||
return (
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<DocumentCheckIcon className="h-8 w-8 text-primary" />
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<DocumentCheckIcon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We found your information from a previous inquiry. Complete a few more details to
|
||||
activate your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We found your information from a previous inquiry. Just set a password to complete your
|
||||
account setup.
|
||||
</p>
|
||||
{prefill?.eligibilityStatus && (
|
||||
<p className="text-sm font-medium text-success">
|
||||
Eligibility Status: {prefill.eligibilityStatus}
|
||||
</p>
|
||||
)}
|
||||
{/* Show what's pre-filled vs what's needed */}
|
||||
<div className="p-4 rounded-xl bg-muted/50 border border-border text-left space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">What we have:</p>
|
||||
<ul className="space-y-1.5">
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Name and email verified</span>
|
||||
</li>
|
||||
{prefill?.address && (
|
||||
<li className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>Address from your inquiry</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">What you'll add:</p>
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-4 text-center">•</span>
|
||||
<span>Phone number</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-4 text-center">•</span>
|
||||
<span>Date of birth</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-4 text-center">•</span>
|
||||
<span>Password</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prefill?.eligibilityStatus && (
|
||||
<p className="text-sm font-medium text-success text-center">
|
||||
Eligibility Status: {prefill.eligibilityStatus}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button onClick={() => goToStep("complete-account")} className="w-full h-11">
|
||||
Complete My Account
|
||||
Continue
|
||||
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -63,6 +63,9 @@ export interface GetStartedState {
|
||||
// Prefill data from existing account
|
||||
prefill: VerifyCodeResponse["prefill"] | null;
|
||||
|
||||
// Handoff token from eligibility check (for pre-filling data after OTP verification)
|
||||
handoffToken: string | null;
|
||||
|
||||
// Loading and error states
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@ -88,6 +91,7 @@ export interface GetStartedState {
|
||||
setAccountStatus: (status: AccountStatus) => void;
|
||||
setPrefill: (prefill: VerifyCodeResponse["prefill"]) => void;
|
||||
setSessionToken: (token: string | null) => void;
|
||||
setHandoffToken: (token: string | null) => void;
|
||||
|
||||
// Reset
|
||||
reset: () => void;
|
||||
@ -115,6 +119,7 @@ const initialState = {
|
||||
accountStatus: null,
|
||||
formData: initialFormData,
|
||||
prefill: null,
|
||||
handoffToken: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
codeSent: false,
|
||||
@ -155,9 +160,12 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const { handoffToken } = get();
|
||||
const result = await api.verifyCode({
|
||||
email: get().formData.email,
|
||||
code,
|
||||
// Pass handoff token if available (from eligibility check flow)
|
||||
...(handoffToken && { handoffToken }),
|
||||
});
|
||||
|
||||
if (result.verified && result.sessionToken && result.accountStatus) {
|
||||
@ -264,6 +272,10 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
|
||||
set({ sessionToken: token, emailVerified: token !== null });
|
||||
},
|
||||
|
||||
setHandoffToken: (token: string | null) => {
|
||||
set({ handoffToken: token });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
/**
|
||||
* GetStartedView - Main view for the get-started flow
|
||||
*
|
||||
* Supports handoff from eligibility check flow:
|
||||
* - URL params: ?email=xxx&verified=true
|
||||
* - SessionStorage: get-started-email, get-started-verified
|
||||
* Supports multiple handoff scenarios from eligibility check:
|
||||
*
|
||||
* 1. Verified handoff (?verified=true):
|
||||
* - User already completed OTP on eligibility page
|
||||
* - SessionStorage has: sessionToken, accountStatus, prefill, email
|
||||
* - Skip directly to complete-account step
|
||||
*
|
||||
* 2. Unverified handoff (?handoff=true):
|
||||
* - User came from eligibility check success page or SF email
|
||||
* - Email is pre-filled but NOT verified yet
|
||||
* - User must complete OTP verification
|
||||
* - SessionStorage may have: handoff-token for prefill data
|
||||
*/
|
||||
|
||||
"use client";
|
||||
@ -12,44 +21,131 @@ import { useState, useCallback, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AuthLayout } from "@/components/templates/AuthLayout";
|
||||
import { GetStartedForm } from "../components";
|
||||
import { useGetStartedStore, type GetStartedStep } from "../stores/get-started.store";
|
||||
import {
|
||||
useGetStartedStore,
|
||||
type GetStartedStep,
|
||||
type GetStartedAddress,
|
||||
} from "../stores/get-started.store";
|
||||
import type { AccountStatus, VerifyCodeResponse } from "@customer-portal/domain/get-started";
|
||||
|
||||
// Session data staleness threshold (5 minutes)
|
||||
const SESSION_STALE_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
|
||||
export function GetStartedView() {
|
||||
const searchParams = useSearchParams();
|
||||
const { updateFormData, goToStep, setAccountStatus, setPrefill, setSessionToken } =
|
||||
useGetStartedStore();
|
||||
const {
|
||||
updateFormData,
|
||||
goToStep,
|
||||
setHandoffToken,
|
||||
setSessionToken,
|
||||
setAccountStatus,
|
||||
setPrefill,
|
||||
} = useGetStartedStore();
|
||||
const [meta, setMeta] = useState({
|
||||
title: "Get Started",
|
||||
subtitle: "Enter your email to begin",
|
||||
});
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Helper to clear all get-started sessionStorage items
|
||||
const clearGetStartedSessionStorage = () => {
|
||||
sessionStorage.removeItem("get-started-session-token");
|
||||
sessionStorage.removeItem("get-started-account-status");
|
||||
sessionStorage.removeItem("get-started-prefill");
|
||||
sessionStorage.removeItem("get-started-email");
|
||||
sessionStorage.removeItem("get-started-timestamp");
|
||||
sessionStorage.removeItem("get-started-handoff-token");
|
||||
};
|
||||
|
||||
// Check for handoff from eligibility check on mount
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
|
||||
// Get params from URL or sessionStorage
|
||||
const emailParam = searchParams.get("email");
|
||||
// Check for verified handoff (user already completed OTP on eligibility page)
|
||||
const verifiedParam = searchParams.get("verified");
|
||||
|
||||
const storedEmail = sessionStorage.getItem("get-started-email");
|
||||
const storedVerified = sessionStorage.getItem("get-started-verified");
|
||||
if (verifiedParam === "true") {
|
||||
// Read all session data at once
|
||||
const storedSessionToken = sessionStorage.getItem("get-started-session-token");
|
||||
const storedAccountStatus = sessionStorage.getItem("get-started-account-status");
|
||||
const storedPrefillRaw = sessionStorage.getItem("get-started-prefill");
|
||||
const storedEmail = sessionStorage.getItem("get-started-email");
|
||||
const storedTimestamp = sessionStorage.getItem("get-started-timestamp");
|
||||
|
||||
// Clear sessionStorage after reading
|
||||
// Validate timestamp to prevent stale data
|
||||
const isStale =
|
||||
!storedTimestamp || Date.now() - parseInt(storedTimestamp, 10) > SESSION_STALE_THRESHOLD_MS;
|
||||
|
||||
// Clear sessionStorage immediately after reading
|
||||
clearGetStartedSessionStorage();
|
||||
|
||||
if (storedSessionToken && !isStale) {
|
||||
// Parse prefill data
|
||||
let prefill: VerifyCodeResponse["prefill"] | null = null;
|
||||
if (storedPrefillRaw) {
|
||||
try {
|
||||
prefill = JSON.parse(storedPrefillRaw);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Set session data in store
|
||||
setSessionToken(storedSessionToken);
|
||||
if (storedAccountStatus) {
|
||||
setAccountStatus(storedAccountStatus as AccountStatus);
|
||||
}
|
||||
if (prefill) {
|
||||
setPrefill(prefill);
|
||||
// Also update form data with prefill
|
||||
updateFormData({
|
||||
email: storedEmail || prefill.email || "",
|
||||
firstName: prefill.firstName || "",
|
||||
lastName: prefill.lastName || "",
|
||||
phone: prefill.phone || "",
|
||||
address: (prefill.address as GetStartedAddress) || {},
|
||||
});
|
||||
} else if (storedEmail) {
|
||||
updateFormData({ email: storedEmail });
|
||||
}
|
||||
|
||||
// Skip directly to complete-account (email already verified)
|
||||
goToStep("complete-account");
|
||||
setInitialized(true);
|
||||
return;
|
||||
}
|
||||
// If stale or no token, fall through to normal flow
|
||||
}
|
||||
|
||||
// Check for unverified handoff (from success page CTA or SF email)
|
||||
const emailParam = searchParams.get("email");
|
||||
const handoffParam = searchParams.get("handoff");
|
||||
const storedHandoffToken = sessionStorage.getItem("get-started-handoff-token");
|
||||
const storedEmail = sessionStorage.getItem("get-started-email");
|
||||
|
||||
// Clear handoff-related sessionStorage after reading
|
||||
sessionStorage.removeItem("get-started-handoff-token");
|
||||
sessionStorage.removeItem("get-started-email");
|
||||
sessionStorage.removeItem("get-started-verified");
|
||||
|
||||
const email = emailParam || storedEmail;
|
||||
const isVerified = verifiedParam === "true" || storedVerified === "true";
|
||||
const isHandoff = handoffParam === "true" || !!storedHandoffToken;
|
||||
|
||||
if (email && isVerified) {
|
||||
// User came from eligibility check - they have a verified email and SF Account
|
||||
if (email && isHandoff) {
|
||||
// User came from eligibility check - email is NOT verified yet
|
||||
// Pre-fill email and let user verify via OTP
|
||||
updateFormData({ email });
|
||||
|
||||
// Store handoff token if available - will be used during OTP verification
|
||||
if (storedHandoffToken) {
|
||||
setHandoffToken(storedHandoffToken);
|
||||
}
|
||||
|
||||
// Stay at email step - user needs to verify their email
|
||||
// Don't call goToStep - let the form start at its default step
|
||||
// The email is pre-filled so user just clicks "Send Code"
|
||||
} else if (email) {
|
||||
// Just email param (from SF email link without handoff)
|
||||
updateFormData({ email });
|
||||
// The email is verified, but we still need to check account status
|
||||
// SF Account was already created during eligibility check, so status should be sf_unmapped
|
||||
setAccountStatus("sf_unmapped");
|
||||
// Go directly to complete-account step
|
||||
goToStep("complete-account");
|
||||
}
|
||||
|
||||
setInitialized(true);
|
||||
@ -57,10 +153,11 @@ export function GetStartedView() {
|
||||
initialized,
|
||||
searchParams,
|
||||
updateFormData,
|
||||
setHandoffToken,
|
||||
goToStep,
|
||||
setSessionToken,
|
||||
setAccountStatus,
|
||||
setPrefill,
|
||||
setSessionToken,
|
||||
]);
|
||||
|
||||
const handleStepChange = useCallback(
|
||||
|
||||
@ -159,12 +159,11 @@ export function PublicLandingView() {
|
||||
</section>
|
||||
|
||||
{/* ===== OUR SERVICES ===== */}
|
||||
<section
|
||||
id="services"
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "600ms" }}
|
||||
>
|
||||
<div className="text-center mb-10">
|
||||
<section id="services">
|
||||
<div
|
||||
className="text-center mb-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "500ms" }}
|
||||
>
|
||||
<h2 className="text-display-sm font-display font-bold text-foreground mb-3">
|
||||
Our Services
|
||||
</h2>
|
||||
@ -189,8 +188,8 @@ export function PublicLandingView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{/* Services Grid - uses cp-stagger-children for consistent card animations */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">
|
||||
<ServiceCard
|
||||
href="/services/internet"
|
||||
icon={<Wifi className="h-6 w-6" />}
|
||||
|
||||
@ -339,11 +339,8 @@ export function SimPlansContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Highlights */}
|
||||
<section
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
{/* Service Highlights - uses cp-stagger-children internally */}
|
||||
<section>
|
||||
<ServiceHighlights features={simFeatures} />
|
||||
</section>
|
||||
|
||||
|
||||
@ -3,28 +3,30 @@
|
||||
*
|
||||
* Flow:
|
||||
* 1. Enter name, email, address
|
||||
* 2. Verify email with OTP
|
||||
* 3. Creates SF Account + Case immediately on verification
|
||||
* 4. Shows options: "Create Account Now" or "Maybe Later"
|
||||
* 2. Submit:
|
||||
* - "Send Request Only" → Success page (with "Create Account" CTA)
|
||||
* - "Continue to Create Account" → Inline OTP → Redirect to complete-account
|
||||
* 3. SF Account + Case created immediately on submit
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle, Mail, ArrowRight, ArrowLeft, Clock, MapPin } from "lucide-react";
|
||||
import { CheckCircle, ArrowRight, Clock, MapPin, UserPlus, Mail, ArrowLeft } from "lucide-react";
|
||||
import { Button, Input, Label } from "@/components/atoms";
|
||||
import { logger } from "@/core/logger";
|
||||
import {
|
||||
JapanAddressForm,
|
||||
type JapanAddressFormData,
|
||||
} from "@/features/address/components/JapanAddressForm";
|
||||
import { OtpInput } from "@/features/get-started/components/OtpInput/OtpInput";
|
||||
import {
|
||||
guestEligibilityCheck,
|
||||
sendVerificationCode,
|
||||
verifyCode,
|
||||
quickEligibilityCheck,
|
||||
} from "@/features/get-started/api/get-started.api";
|
||||
import { OtpInput } from "@/features/get-started/components/OtpInput/OtpInput";
|
||||
import { prepareWhmcsAddressFields } from "@customer-portal/domain/address";
|
||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||
@ -56,7 +58,7 @@ interface FormErrors {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Step Components
|
||||
// Form Step Component
|
||||
// ============================================================================
|
||||
|
||||
interface FormStepProps {
|
||||
@ -68,7 +70,8 @@ interface FormStepProps {
|
||||
onFormDataChange: (data: Partial<FormData>) => void;
|
||||
onAddressChange: (data: JapanAddressFormData, isComplete: boolean) => void;
|
||||
onClearError: (field: keyof FormErrors) => void;
|
||||
onSubmit: () => void;
|
||||
onSubmitOnly: () => void;
|
||||
onSubmitAndCreate: () => void;
|
||||
}
|
||||
|
||||
function FormStep({
|
||||
@ -79,7 +82,8 @@ function FormStep({
|
||||
onFormDataChange,
|
||||
onAddressChange,
|
||||
onClearError,
|
||||
onSubmit,
|
||||
onSubmitOnly,
|
||||
onSubmitAndCreate,
|
||||
}: FormStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -139,6 +143,9 @@ function FormStep({
|
||||
disabled={loading}
|
||||
error={formErrors.email}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We'll send availability results to this email
|
||||
</p>
|
||||
{formErrors.email && <p className="text-sm text-danger">{formErrors.email}</p>}
|
||||
</div>
|
||||
|
||||
@ -158,102 +165,160 @@ function FormStep({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
className="w-full h-12"
|
||||
>
|
||||
{loading ? "Sending Code..." : "Continue"}
|
||||
{!loading && <ArrowRight className="h-4 w-4 ml-2" />}
|
||||
</Button>
|
||||
{/* Two submission options */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmitAndCreate}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
className="w-full h-12"
|
||||
>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Continue to Create Account
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSubmitOnly}
|
||||
disabled={loading}
|
||||
className="w-full h-12"
|
||||
>
|
||||
Send Request Only
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
You can create your account anytime later using the same email address.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OTP Step Component
|
||||
// ============================================================================
|
||||
|
||||
interface OtpStepProps {
|
||||
email: string;
|
||||
otpCode: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
attemptsRemaining: number | null;
|
||||
onOtpChange: (code: string) => void;
|
||||
resendDisabled: boolean;
|
||||
resendCountdown: number;
|
||||
onVerify: (code: string) => void;
|
||||
onResend: () => void;
|
||||
onBack: () => void;
|
||||
onChangeEmail: () => void;
|
||||
}
|
||||
|
||||
function OtpStep({
|
||||
email,
|
||||
otpCode,
|
||||
loading,
|
||||
error,
|
||||
attemptsRemaining,
|
||||
onOtpChange,
|
||||
resendDisabled,
|
||||
resendCountdown,
|
||||
onVerify,
|
||||
onResend,
|
||||
onBack,
|
||||
onChangeEmail,
|
||||
}: OtpStepProps) {
|
||||
const [otpValue, setOtpValue] = useState("");
|
||||
|
||||
const handleComplete = useCallback(
|
||||
(code: string) => {
|
||||
onVerify(code);
|
||||
},
|
||||
[onVerify]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-16 w-16 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center">
|
||||
<Mail className="h-8 w-8 text-primary" />
|
||||
<div className="h-14 w-14 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Mail className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">Check your email</h2>
|
||||
<p className="text-sm text-muted-foreground">We sent a verification code to</p>
|
||||
<p className="font-medium text-foreground mt-1">{email}</p>
|
||||
<h2 className="text-xl font-semibold text-foreground">Verify Your Email</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
We sent a 6-digit code to <span className="font-medium text-foreground">{email}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OTP Input */}
|
||||
<div className="flex justify-center">
|
||||
<div className="py-4">
|
||||
<OtpInput
|
||||
value={otpCode}
|
||||
onChange={onOtpChange}
|
||||
onComplete={onVerify}
|
||||
length={6}
|
||||
value={otpValue}
|
||||
onChange={setOtpValue}
|
||||
onComplete={handleComplete}
|
||||
disabled={loading}
|
||||
error={
|
||||
error && attemptsRemaining !== null
|
||||
? `${error} (${attemptsRemaining} attempts remaining)`
|
||||
: (error ?? undefined)
|
||||
}
|
||||
error={error || undefined}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resend */}
|
||||
<div className="text-center">
|
||||
{/* Attempts remaining warning */}
|
||||
{attemptsRemaining !== null && attemptsRemaining < 3 && (
|
||||
<p className="text-sm text-warning text-center">
|
||||
{attemptsRemaining} attempt{attemptsRemaining !== 1 ? "s" : ""} remaining
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Verify Button */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onVerify(otpValue)}
|
||||
disabled={loading || otpValue.length !== 6}
|
||||
loading={loading}
|
||||
className="w-full h-12"
|
||||
>
|
||||
Verify & Continue
|
||||
</Button>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col items-center gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResend}
|
||||
disabled={loading}
|
||||
className="text-sm text-primary hover:underline disabled:opacity-50"
|
||||
disabled={loading || resendDisabled}
|
||||
className="text-sm text-primary hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Didn't receive the code? Resend
|
||||
{resendDisabled && resendCountdown > 0
|
||||
? `Resend code in ${resendCountdown}s`
|
||||
: "Resend code"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeEmail}
|
||||
disabled={loading}
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Change email address
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Back */}
|
||||
<Button type="button" variant="ghost" onClick={onBack} disabled={loading} className="w-full">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Success Step Component
|
||||
// ============================================================================
|
||||
|
||||
interface SuccessStepProps {
|
||||
email: string;
|
||||
requestId: string | null;
|
||||
onCreateAccount: () => void;
|
||||
onMaybeLater: () => void;
|
||||
onBackToPlans: () => void;
|
||||
}
|
||||
|
||||
function SuccessStep({ requestId, onCreateAccount, onMaybeLater }: SuccessStepProps) {
|
||||
function SuccessStep({ email, requestId, onBackToPlans }: SuccessStepProps) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
@ -266,7 +331,7 @@ function SuccessStep({ requestId, onCreateAccount, onMaybeLater }: SuccessStepPr
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3">Request Submitted!</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
We're checking internet availability at your address. Our team will review this and
|
||||
get back to you within 1-2 business days.
|
||||
email you the results within 1-2 business days.
|
||||
</p>
|
||||
</div>
|
||||
{requestId && (
|
||||
@ -278,22 +343,35 @@ function SuccessStep({ requestId, onCreateAccount, onMaybeLater }: SuccessStepPr
|
||||
|
||||
{/* What's next */}
|
||||
<div className="bg-muted/50 rounded-xl p-6 space-y-4">
|
||||
<h3 className="font-semibold text-foreground text-center">
|
||||
What would you like to do next?
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<Button type="button" onClick={onCreateAccount} className="w-full h-12">
|
||||
Create Account Now
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="outline" onClick={onMaybeLater} className="w-full h-12">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
Maybe Later
|
||||
</Button>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-5 w-5" />
|
||||
<span className="text-sm">Check your email for updates</span>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" onClick={onBackToPlans} className="w-full h-12">
|
||||
Back to Internet Plans
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="bg-muted/50 px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Account CTA */}
|
||||
<Link href={`/auth/get-started?email=${encodeURIComponent(email)}`} className="block">
|
||||
<Button type="button" variant="ghost" className="w-full h-12">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Create Your Account Now
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
You can create your account anytime using the same email address.
|
||||
Creating an account lets you track your request and order services faster.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -322,12 +400,25 @@ export function PublicEligibilityCheckView() {
|
||||
const [isAddressComplete, setIsAddressComplete] = useState(false);
|
||||
|
||||
// OTP state
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
const [handoffToken, setHandoffToken] = useState<string | null>(null);
|
||||
const [otpError, setOtpError] = useState<string | null>(null);
|
||||
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
|
||||
const [resendDisabled, setResendDisabled] = useState(false);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
const resendTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Success state
|
||||
const [requestId, setRequestId] = useState<string | null>(null);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resendTimerRef.current) {
|
||||
clearInterval(resendTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle form data changes
|
||||
const handleFormDataChange = useCallback((data: Partial<FormData>) => {
|
||||
setFormData(prev => ({ ...prev, ...data }));
|
||||
@ -375,54 +466,41 @@ export function PublicEligibilityCheckView() {
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
// Submit form and send OTP
|
||||
const handleFormSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
// Start resend countdown timer
|
||||
const startResendTimer = useCallback(() => {
|
||||
setResendDisabled(true);
|
||||
setResendCountdown(60);
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await sendVerificationCode({ email: formData.email });
|
||||
setStep("otp");
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (resendTimerRef.current) {
|
||||
clearInterval(resendTimerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
// Verify OTP and create SF Account
|
||||
const handleVerifyOtp = async (code: string) => {
|
||||
if (code.length !== 6) return;
|
||||
resendTimerRef.current = setInterval(() => {
|
||||
setResendCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
if (resendTimerRef.current) {
|
||||
clearInterval(resendTimerRef.current);
|
||||
}
|
||||
setResendDisabled(false);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
// Submit eligibility check (core logic)
|
||||
const submitEligibilityCheck = async (continueToAccount: boolean) => {
|
||||
if (!validateForm()) return null;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Step 1: Verify the OTP
|
||||
const verifyResult = await verifyCode({
|
||||
email: formData.email,
|
||||
code,
|
||||
});
|
||||
|
||||
if (!verifyResult.verified) {
|
||||
setAttemptsRemaining(verifyResult.attemptsRemaining ?? null);
|
||||
setError(verifyResult.error || "Invalid verification code");
|
||||
setOtpCode(""); // Clear for retry
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifyResult.sessionToken) {
|
||||
setError("Session error. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Immediately create SF Account + Case
|
||||
const whmcsAddress = formData.address ? prepareWhmcsAddressFields(formData.address) : null;
|
||||
|
||||
const eligibilityResult = await quickEligibilityCheck({
|
||||
sessionToken: verifyResult.sessionToken,
|
||||
const result = await guestEligibilityCheck({
|
||||
email: formData.email.trim(),
|
||||
firstName: formData.firstName.trim(),
|
||||
lastName: formData.lastName.trim(),
|
||||
address: {
|
||||
@ -433,71 +511,200 @@ export function PublicEligibilityCheckView() {
|
||||
postcode: whmcsAddress?.postcode || "",
|
||||
country: "JP",
|
||||
},
|
||||
continueToAccount,
|
||||
});
|
||||
|
||||
if (eligibilityResult.submitted) {
|
||||
setRequestId(eligibilityResult.requestId || null);
|
||||
setStep("success");
|
||||
} else {
|
||||
setError(eligibilityResult.message || "Failed to submit eligibility check");
|
||||
if (!result.submitted) {
|
||||
setError(result.message || "Failed to submit eligibility check");
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
logger.error("Failed to submit eligibility check", { error: message, email: formData.email });
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle resend OTP
|
||||
const handleResendCode = async () => {
|
||||
// Handle "Send Request Only" - submit and show success
|
||||
const handleSubmitOnly = async () => {
|
||||
const result = await submitEligibilityCheck(false);
|
||||
if (result) {
|
||||
setRequestId(result.requestId || null);
|
||||
setStep("success");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "Continue to Create Account" - submit, send OTP, show inline OTP
|
||||
const handleSubmitAndCreate = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setOtpCode("");
|
||||
|
||||
try {
|
||||
await sendVerificationCode({ email: formData.email });
|
||||
setAttemptsRemaining(null);
|
||||
// 1. Submit eligibility check (creates SF Account + Case)
|
||||
const whmcsAddress = formData.address ? prepareWhmcsAddressFields(formData.address) : null;
|
||||
|
||||
const eligibilityResult = await guestEligibilityCheck({
|
||||
email: formData.email.trim(),
|
||||
firstName: formData.firstName.trim(),
|
||||
lastName: formData.lastName.trim(),
|
||||
address: {
|
||||
address1: whmcsAddress?.address1 || "",
|
||||
address2: whmcsAddress?.address2 || "",
|
||||
city: whmcsAddress?.city || "",
|
||||
state: whmcsAddress?.state || "",
|
||||
postcode: whmcsAddress?.postcode || "",
|
||||
country: "JP",
|
||||
},
|
||||
continueToAccount: true,
|
||||
});
|
||||
|
||||
if (!eligibilityResult.submitted) {
|
||||
setError(eligibilityResult.message || "Failed to submit eligibility check");
|
||||
return;
|
||||
}
|
||||
|
||||
// Store handoff token for OTP verification
|
||||
if (eligibilityResult.handoffToken) {
|
||||
setHandoffToken(eligibilityResult.handoffToken);
|
||||
}
|
||||
|
||||
// 2. Send OTP code
|
||||
const otpResult = await sendVerificationCode({ email: formData.email.trim() });
|
||||
|
||||
if (otpResult.sent) {
|
||||
setStep("otp");
|
||||
startResendTimer();
|
||||
} else {
|
||||
setError(otpResult.message || "Failed to send verification code");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
const message = getErrorMessage(err);
|
||||
logger.error("Failed to submit eligibility and send OTP", {
|
||||
error: message,
|
||||
email: formData.email,
|
||||
});
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "Create Account Now" - redirect to get-started with email
|
||||
const handleCreateAccount = () => {
|
||||
// Store email in sessionStorage for get-started page to pick up
|
||||
sessionStorage.setItem("get-started-email", formData.email);
|
||||
sessionStorage.setItem("get-started-verified", "true");
|
||||
router.push(`/auth/get-started?email=${encodeURIComponent(formData.email)}&verified=true`);
|
||||
// Handle OTP verification
|
||||
const handleVerifyOtp = async (code: string) => {
|
||||
if (code.length !== 6) return;
|
||||
|
||||
setLoading(true);
|
||||
setOtpError(null);
|
||||
|
||||
try {
|
||||
const result = await verifyCode({
|
||||
email: formData.email.trim(),
|
||||
code,
|
||||
...(handoffToken && { handoffToken }),
|
||||
});
|
||||
|
||||
if (result.verified && result.sessionToken) {
|
||||
// Clear timer immediately on success
|
||||
if (resendTimerRef.current) {
|
||||
clearInterval(resendTimerRef.current);
|
||||
}
|
||||
setResendDisabled(false);
|
||||
setResendCountdown(0);
|
||||
|
||||
// Store session data for get-started page with timestamp for staleness validation
|
||||
sessionStorage.setItem("get-started-session-token", result.sessionToken);
|
||||
sessionStorage.setItem("get-started-account-status", result.accountStatus || "");
|
||||
sessionStorage.setItem("get-started-email", formData.email);
|
||||
sessionStorage.setItem("get-started-timestamp", Date.now().toString());
|
||||
|
||||
// Store prefill data if available
|
||||
if (result.prefill) {
|
||||
sessionStorage.setItem("get-started-prefill", JSON.stringify(result.prefill));
|
||||
}
|
||||
|
||||
// Redirect to complete-account step directly
|
||||
router.push("/auth/get-started?verified=true");
|
||||
} else {
|
||||
setOtpError(result.error || "Verification failed. Please try again.");
|
||||
setAttemptsRemaining(result.attemptsRemaining ?? null);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
logger.error("Failed to verify OTP", { error: message, email: formData.email });
|
||||
setOtpError(message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "Maybe Later"
|
||||
const handleMaybeLater = () => {
|
||||
// SF Account already created during quickEligibilityCheck, just go back to plans
|
||||
// Handle OTP resend
|
||||
const handleResendOtp = async () => {
|
||||
if (resendDisabled) return;
|
||||
|
||||
setLoading(true);
|
||||
setOtpError(null);
|
||||
|
||||
try {
|
||||
const result = await sendVerificationCode({ email: formData.email.trim() });
|
||||
|
||||
if (result.sent) {
|
||||
startResendTimer();
|
||||
} else {
|
||||
setOtpError(result.message || "Failed to resend code");
|
||||
}
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
logger.error("Failed to resend OTP", { error: message, email: formData.email });
|
||||
setOtpError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "Change email" from OTP step
|
||||
const handleChangeEmail = () => {
|
||||
setStep("form");
|
||||
setOtpError(null);
|
||||
setAttemptsRemaining(null);
|
||||
setHandoffToken(null);
|
||||
if (resendTimerRef.current) {
|
||||
clearInterval(resendTimerRef.current);
|
||||
}
|
||||
setResendDisabled(false);
|
||||
setResendCountdown(0);
|
||||
};
|
||||
|
||||
// Handle "Back to Plans"
|
||||
const handleBackToPlans = () => {
|
||||
router.push(`${servicesBasePath}/internet`);
|
||||
};
|
||||
|
||||
// Handle back from OTP step
|
||||
const handleBackFromOtp = () => {
|
||||
setStep("form");
|
||||
setError(null);
|
||||
setOtpCode("");
|
||||
};
|
||||
|
||||
// Step titles and descriptions
|
||||
const stepMeta = {
|
||||
// Step meta for header
|
||||
const stepMeta: Record<
|
||||
Step,
|
||||
{ title: string; description: string; icon: "form" | "otp" | "success" }
|
||||
> = {
|
||||
form: {
|
||||
title: "Check Availability",
|
||||
description: "Enter your details to check if internet service is available at your address.",
|
||||
icon: "form",
|
||||
},
|
||||
otp: {
|
||||
title: "Verify Email",
|
||||
description: "We need to verify your email before checking availability.",
|
||||
description: "Enter the verification code we sent to your email.",
|
||||
icon: "otp",
|
||||
},
|
||||
success: {
|
||||
title: "Request Submitted",
|
||||
description: "Your availability check request has been submitted.",
|
||||
icon: "success",
|
||||
},
|
||||
};
|
||||
|
||||
@ -509,9 +716,9 @@ export function PublicEligibilityCheckView() {
|
||||
<div className="mt-8 mb-8 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/20">
|
||||
{step === "form" && <MapPin className="h-7 w-7 text-primary" />}
|
||||
{step === "otp" && <Mail className="h-7 w-7 text-primary" />}
|
||||
{step === "success" && <CheckCircle className="h-7 w-7 text-success" />}
|
||||
{stepMeta[step].icon === "form" && <MapPin className="h-7 w-7 text-primary" />}
|
||||
{stepMeta[step].icon === "otp" && <Mail className="h-7 w-7 text-primary" />}
|
||||
{stepMeta[step].icon === "success" && <CheckCircle className="h-7 w-7 text-success" />}
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2">
|
||||
@ -522,22 +729,6 @@ export function PublicEligibilityCheckView() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
{step !== "success" && (
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{["form", "otp"].map((s, i) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div
|
||||
className={`h-2 w-8 rounded-full transition-colors ${
|
||||
step === s || (step === "otp" && s === "form") ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
{i < 1 && <div className="h-px w-4 bg-border" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-card border border-border rounded-2xl p-6 sm:p-8 shadow-[var(--cp-shadow-1)]">
|
||||
{/* Form Step */}
|
||||
@ -551,7 +742,8 @@ export function PublicEligibilityCheckView() {
|
||||
onFormDataChange={handleFormDataChange}
|
||||
onAddressChange={handleAddressChange}
|
||||
onClearError={handleClearError}
|
||||
onSubmit={handleFormSubmit}
|
||||
onSubmitOnly={handleSubmitOnly}
|
||||
onSubmitAndCreate={handleSubmitAndCreate}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -559,23 +751,23 @@ export function PublicEligibilityCheckView() {
|
||||
{step === "otp" && (
|
||||
<OtpStep
|
||||
email={formData.email}
|
||||
otpCode={otpCode}
|
||||
loading={loading}
|
||||
error={error}
|
||||
error={otpError}
|
||||
attemptsRemaining={attemptsRemaining}
|
||||
onOtpChange={setOtpCode}
|
||||
resendDisabled={resendDisabled}
|
||||
resendCountdown={resendCountdown}
|
||||
onVerify={handleVerifyOtp}
|
||||
onResend={handleResendCode}
|
||||
onBack={handleBackFromOtp}
|
||||
onResend={handleResendOtp}
|
||||
onChangeEmail={handleChangeEmail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Success Step */}
|
||||
{step === "success" && (
|
||||
<SuccessStep
|
||||
email={formData.email}
|
||||
requestId={requestId}
|
||||
onCreateAccount={handleCreateAccount}
|
||||
onMaybeLater={handleMaybeLater}
|
||||
onBackToPlans={handleBackToPlans}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -305,20 +305,17 @@ export function PublicInternetPlansContent({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Service Highlights */}
|
||||
<section
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
{/* Service Highlights - uses cp-stagger-children internally */}
|
||||
<section>
|
||||
<ServiceHighlights features={INTERNET_FEATURES} />
|
||||
</section>
|
||||
|
||||
{/* Connection types section */}
|
||||
<section
|
||||
className="space-y-4 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<section className="space-y-4">
|
||||
<div
|
||||
className="text-center mb-6 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
Choose Your Connection
|
||||
</p>
|
||||
|
||||
@ -149,11 +149,8 @@ export function PublicVpnPlansView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Highlights */}
|
||||
<section
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
{/* Service Highlights - uses cp-stagger-children internally */}
|
||||
<section>
|
||||
<ServiceHighlights features={VPN_FEATURES} />
|
||||
</section>
|
||||
|
||||
|
||||
@ -34,19 +34,26 @@ For customers who want to check internet availability before creating an account
|
||||
│
|
||||
└─→ /services/internet/check-availability (dedicated page)
|
||||
│
|
||||
├─→ Step 1: Enter name, email, address
|
||||
├─→ Step 1: Enter name, email, address (with Japan ZIP lookup)
|
||||
│
|
||||
├─→ Step 2: Verify email (6-digit OTP)
|
||||
│
|
||||
└─→ Step 3: SF Account + Case created immediately
|
||||
└─→ Step 2: Choose action:
|
||||
│
|
||||
├─→ "Create Account Now" → Redirect to /auth/get-started
|
||||
│ (email pre-verified, goes to complete-account)
|
||||
├─→ "Send Request Only"
|
||||
│ └─→ SF Account + Case created → Success page
|
||||
│ └─→ Success page shows:
|
||||
│ ├─→ "Back to Internet Plans" → Return to /services/internet
|
||||
│ └─→ "Create Your Account Now" → /auth/get-started?email=xxx
|
||||
│ (standard OTP flow)
|
||||
│
|
||||
└─→ "Maybe Later" → Return to /services/internet
|
||||
(SF Account created for agent to review)
|
||||
└─→ "Continue to Create Account"
|
||||
├─→ SF Account + Case created
|
||||
├─→ Inline OTP verification (no redirect)
|
||||
└─→ On success → /auth/get-started?verified=true
|
||||
(skips email/OTP steps, goes to complete-account)
|
||||
```
|
||||
|
||||
**Key difference from Phase 1:** The "Continue to Create Account" path now includes inline OTP verification directly on the eligibility page, rather than redirecting to `/auth/get-started` for OTP.
|
||||
|
||||
## Account Status Routing
|
||||
|
||||
| Portal | WHMCS | Salesforce | Mapping | → Result |
|
||||
@ -128,32 +135,73 @@ Key schemas:
|
||||
|
||||
## Handoff from Eligibility Check
|
||||
|
||||
When a user clicks "Create Account Now" from the eligibility check page:
|
||||
### Flow A: "Continue to Create Account" (Inline OTP)
|
||||
|
||||
1. Email stored in sessionStorage: `get-started-email`
|
||||
2. Verification flag stored: `get-started-verified=true`
|
||||
3. Redirect to: `/auth/get-started?email={email}&verified=true`
|
||||
4. GetStartedView detects handoff and:
|
||||
- Sets account status to `sf_unmapped` (SF Account was created during eligibility)
|
||||
- Skips to `complete-account` step
|
||||
- User only needs to add password + profile details
|
||||
When a user clicks "Continue to Create Account":
|
||||
|
||||
When a user clicks "Maybe Later":
|
||||
1. Eligibility form is submitted (creates SF Account + Case)
|
||||
2. OTP is sent and verified **inline on the same page**
|
||||
3. On successful verification:
|
||||
- Session data stored in sessionStorage with timestamp:
|
||||
- `get-started-session-token`
|
||||
- `get-started-account-status`
|
||||
- `get-started-prefill` (JSON with name, address from SF)
|
||||
- `get-started-email`
|
||||
- `get-started-timestamp` (for staleness validation)
|
||||
- Redirect to: `/auth/get-started?verified=true`
|
||||
4. GetStartedView detects `?verified=true` param and:
|
||||
- Reads session data from sessionStorage (validates timestamp < 5 min)
|
||||
- Clears sessionStorage immediately after reading
|
||||
- Sets session token, account status, and prefill data in Zustand store
|
||||
- Skips directly to `complete-account` step (no email/OTP required)
|
||||
- User only needs to add phone, DOB, and password
|
||||
|
||||
- Returns to `/services/internet` plans page
|
||||
- SF Account already created during eligibility check (agent can review)
|
||||
- User can return anytime and use the same email to continue
|
||||
### Flow B: "Send Request Only" → Return Later
|
||||
|
||||
When a user clicks "Send Request Only":
|
||||
|
||||
1. Eligibility form is submitted (creates SF Account + Case)
|
||||
2. Success page is shown with two options:
|
||||
- **"Back to Internet Plans"** → Returns to `/services/internet`
|
||||
- **"Create Your Account Now"** → Redirects to `/auth/get-started?email=xxx&handoff=true`
|
||||
3. If user returns later via success page CTA or SF email:
|
||||
- Standard flow: Email (pre-filled) → OTP → Account Status → Complete
|
||||
- Backend detects `sf_unmapped` status and returns prefill data
|
||||
|
||||
### Salesforce Email Link Format
|
||||
|
||||
SF can send "finish your account" emails with this link format:
|
||||
|
||||
```
|
||||
https://portal.example.com/auth/get-started?email={Account.PersonEmail}
|
||||
```
|
||||
|
||||
- No handoff token needed (SF Account persists)
|
||||
- User verifies via standard OTP flow on get-started page
|
||||
- Backend detects `sf_unmapped` status and pre-fills form data
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **New customer flow**: Enter new email → Verify OTP → Full signup form
|
||||
2. **SF-only flow**: Enter email with SF account → Verify → Pre-filled form, just add password
|
||||
2. **SF-only flow**: Enter email with SF account → Verify → Pre-filled form (name, address pre-filled, add phone, DOB, password)
|
||||
3. **WHMCS migration**: Enter email with WHMCS → Verify → Enter WHMCS password
|
||||
4. **Eligibility check**: Click "Check Availability" on plans page → Dedicated page → Enter details → OTP → "Create Account" or "Maybe Later"
|
||||
5. **Return flow**: Customer returns, enters same email → Auto-links to SF account
|
||||
6. **Mobile experience**: Test eligibility check page on mobile viewport
|
||||
4. **Eligibility check - Send Request Only**:
|
||||
- Click "Check Availability" → Fill form → Click "Send Request Only"
|
||||
- Verify success page shows "Back to Plans" and "Create Account" buttons
|
||||
- Click "Create Account" → Verify redirect to `/auth/get-started?email=xxx`
|
||||
- Complete standard OTP flow → Verify sf_unmapped prefill works
|
||||
5. **Eligibility check - Continue to Create Account**:
|
||||
- Click "Check Availability" → Fill form → Click "Continue to Create Account"
|
||||
- Verify inline OTP step appears (no redirect)
|
||||
- Complete OTP → Verify redirect to `/auth/get-started?verified=true`
|
||||
- Verify CompleteAccountStep shows directly (skips email/OTP steps)
|
||||
- Verify form is pre-filled with name and address
|
||||
6. **Return flow**: Customer returns, enters same email → Auto-links to SF account
|
||||
7. **Mobile experience**: Test eligibility check page on mobile viewport
|
||||
8. **Browser back button**: After OTP success, press back → Verify graceful handling
|
||||
9. **Session timeout**: Wait 5+ minutes after OTP → Verify stale data is rejected
|
||||
|
||||
### Security Testing
|
||||
|
||||
|
||||
@ -17,6 +17,9 @@ import type {
|
||||
verifyCodeResponseSchema,
|
||||
quickEligibilityRequestSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
guestEligibilityRequestSchema,
|
||||
guestEligibilityResponseSchema,
|
||||
guestHandoffTokenSchema,
|
||||
completeAccountRequestSchema,
|
||||
maybeLaterRequestSchema,
|
||||
maybeLaterResponseSchema,
|
||||
@ -84,6 +87,7 @@ export type GetStartedErrorCode =
|
||||
export type SendVerificationCodeRequest = z.infer<typeof sendVerificationCodeRequestSchema>;
|
||||
export type VerifyCodeRequest = z.infer<typeof verifyCodeRequestSchema>;
|
||||
export type QuickEligibilityRequest = z.infer<typeof quickEligibilityRequestSchema>;
|
||||
export type GuestEligibilityRequest = z.infer<typeof guestEligibilityRequestSchema>;
|
||||
export type CompleteAccountRequest = z.infer<typeof completeAccountRequestSchema>;
|
||||
export type MaybeLaterRequest = z.infer<typeof maybeLaterRequestSchema>;
|
||||
|
||||
@ -94,8 +98,15 @@ export type MaybeLaterRequest = z.infer<typeof maybeLaterRequestSchema>;
|
||||
export type SendVerificationCodeResponse = z.infer<typeof sendVerificationCodeResponseSchema>;
|
||||
export type VerifyCodeResponse = z.infer<typeof verifyCodeResponseSchema>;
|
||||
export type QuickEligibilityResponse = z.infer<typeof quickEligibilityResponseSchema>;
|
||||
export type GuestEligibilityResponse = z.infer<typeof guestEligibilityResponseSchema>;
|
||||
export type MaybeLaterResponse = z.infer<typeof maybeLaterResponseSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Handoff Token Types
|
||||
// ============================================================================
|
||||
|
||||
export type GuestHandoffToken = z.infer<typeof guestHandoffTokenSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Session Types
|
||||
// ============================================================================
|
||||
|
||||
@ -26,6 +26,9 @@ export {
|
||||
type VerifyCodeResponse,
|
||||
type QuickEligibilityRequest,
|
||||
type QuickEligibilityResponse,
|
||||
type GuestEligibilityRequest,
|
||||
type GuestEligibilityResponse,
|
||||
type GuestHandoffToken,
|
||||
type CompleteAccountRequest,
|
||||
type MaybeLaterRequest,
|
||||
type MaybeLaterResponse,
|
||||
@ -45,9 +48,13 @@ export {
|
||||
verifyCodeRequestSchema,
|
||||
verifyCodeResponseSchema,
|
||||
accountStatusSchema,
|
||||
// Quick eligibility schemas
|
||||
// Quick eligibility schemas (OTP-verified)
|
||||
quickEligibilityRequestSchema,
|
||||
quickEligibilityResponseSchema,
|
||||
// Guest eligibility schemas (no OTP required)
|
||||
guestEligibilityRequestSchema,
|
||||
guestEligibilityResponseSchema,
|
||||
guestHandoffTokenSchema,
|
||||
// Account completion schemas
|
||||
completeAccountRequestSchema,
|
||||
// Maybe later schemas
|
||||
|
||||
@ -52,6 +52,8 @@ export const otpCodeSchema = z
|
||||
export const verifyCodeRequestSchema = z.object({
|
||||
email: emailSchema,
|
||||
code: otpCodeSchema,
|
||||
/** Optional handoff token from guest eligibility check - used to pre-fill data */
|
||||
handoffToken: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -135,6 +137,72 @@ export const quickEligibilityResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Guest Eligibility Check Schemas (No OTP Required)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request for guest eligibility check - NO email verification required
|
||||
* Allows users to check availability without verifying email first
|
||||
*/
|
||||
export const guestEligibilityRequestSchema = z.object({
|
||||
/** Customer email (for notifications, not verified) */
|
||||
email: emailSchema,
|
||||
/** Customer first name */
|
||||
firstName: nameSchema,
|
||||
/** Customer last name */
|
||||
lastName: nameSchema,
|
||||
/** Full address for eligibility check */
|
||||
address: addressFormSchema,
|
||||
/** Optional phone number */
|
||||
phone: phoneSchema.optional(),
|
||||
/** Whether user wants to continue to account creation */
|
||||
continueToAccount: z.boolean().default(false),
|
||||
});
|
||||
|
||||
/**
|
||||
* Response from guest eligibility check
|
||||
*/
|
||||
export const guestEligibilityResponseSchema = z.object({
|
||||
/** Whether the request was submitted successfully */
|
||||
submitted: z.boolean(),
|
||||
/** Case ID for the eligibility request */
|
||||
requestId: z.string().optional(),
|
||||
/** SF Account ID created */
|
||||
sfAccountId: z.string().optional(),
|
||||
/** Message to display */
|
||||
message: z.string(),
|
||||
/** Handoff token for account creation flow (if continueToAccount was true) */
|
||||
handoffToken: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Guest handoff token data stored in Redis
|
||||
* Used to transfer data from eligibility check to account creation
|
||||
*/
|
||||
export const guestHandoffTokenSchema = z.object({
|
||||
/** Token ID */
|
||||
id: z.string(),
|
||||
/** Token type identifier */
|
||||
type: z.literal("guest_handoff"),
|
||||
/** Email address (NOT verified) */
|
||||
email: z.string(),
|
||||
/** Whether email has been verified (always false for guest handoff) */
|
||||
emailVerified: z.literal(false),
|
||||
/** First name */
|
||||
firstName: z.string(),
|
||||
/** Last name */
|
||||
lastName: z.string(),
|
||||
/** Address from eligibility check */
|
||||
address: addressFormSchema.partial().optional(),
|
||||
/** Phone number if provided */
|
||||
phone: z.string().optional(),
|
||||
/** SF Account ID created during eligibility check */
|
||||
sfAccountId: z.string(),
|
||||
/** Token creation timestamp */
|
||||
createdAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Account Completion Schemas
|
||||
// ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user