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:
barsa 2026-01-14 17:14:07 +09:00
parent bb4be98444
commit 1d1602f5e7
20 changed files with 1018 additions and 281 deletions

View File

@ -4,7 +4,11 @@ import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; 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"; import { CacheService } from "@/infra/cache/cache.service.js";
@ -31,7 +35,9 @@ interface SessionData extends Omit<GetStartedSession, "expiresAt"> {
@Injectable() @Injectable()
export class GetStartedSessionService { export class GetStartedSessionService {
private readonly SESSION_PREFIX = "get-started-session:"; private readonly SESSION_PREFIX = "get-started-session:";
private readonly HANDOFF_PREFIX = "guest-handoff:";
private readonly ttlSeconds: number; private readonly ttlSeconds: number;
private readonly handoffTtlSeconds = 1800; // 30 minutes for handoff tokens
constructor( constructor(
private readonly cache: CacheService, private readonly cache: CacheService,
@ -193,10 +199,92 @@ export class GetStartedSessionService {
return session; 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 { private buildKey(sessionId: string): string {
return `${this.SESSION_PREFIX}${sessionId}`; return `${this.SESSION_PREFIX}${sessionId}`;
} }
private buildHandoffKey(tokenId: string): string {
return `${this.HANDOFF_PREFIX}${tokenId}`;
}
private calculateExpiresAt(createdAt: string): string { private calculateExpiresAt(createdAt: string): string {
const created = new Date(createdAt); const created = new Date(createdAt);
const expires = new Date(created.getTime() + this.ttlSeconds * 1000); const expires = new Date(created.getTime() + this.ttlSeconds * 1000);

View File

@ -12,6 +12,8 @@ import {
type VerifyCodeResponse, type VerifyCodeResponse,
type QuickEligibilityRequest, type QuickEligibilityRequest,
type QuickEligibilityResponse, type QuickEligibilityResponse,
type GuestEligibilityRequest,
type GuestEligibilityResponse,
type CompleteAccountRequest, type CompleteAccountRequest,
type MaybeLaterRequest, type MaybeLaterRequest,
type MaybeLaterResponse, 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) // 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( private async determineAccountStatus(
email: string email: string
): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { ): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> {

View File

@ -13,6 +13,8 @@ import {
verifyCodeResponseSchema, verifyCodeResponseSchema,
quickEligibilityRequestSchema, quickEligibilityRequestSchema,
quickEligibilityResponseSchema, quickEligibilityResponseSchema,
guestEligibilityRequestSchema,
guestEligibilityResponseSchema,
completeAccountRequestSchema, completeAccountRequestSchema,
maybeLaterRequestSchema, maybeLaterRequestSchema,
maybeLaterResponseSchema, maybeLaterResponseSchema,
@ -27,6 +29,8 @@ class VerifyCodeRequestDto extends createZodDto(verifyCodeRequestSchema) {}
class VerifyCodeResponseDto extends createZodDto(verifyCodeResponseSchema) {} class VerifyCodeResponseDto extends createZodDto(verifyCodeResponseSchema) {}
class QuickEligibilityRequestDto extends createZodDto(quickEligibilityRequestSchema) {} class QuickEligibilityRequestDto extends createZodDto(quickEligibilityRequestSchema) {}
class QuickEligibilityResponseDto extends createZodDto(quickEligibilityResponseSchema) {} class QuickEligibilityResponseDto extends createZodDto(quickEligibilityResponseSchema) {}
class GuestEligibilityRequestDto extends createZodDto(guestEligibilityRequestSchema) {}
class GuestEligibilityResponseDto extends createZodDto(guestEligibilityResponseSchema) {}
class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {} class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {}
class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {} class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {}
class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {} class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {}
@ -111,6 +115,28 @@ export class GetStartedController {
return this.workflow.quickEligibilityCheck(body); 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 * "Maybe Later" flow
* Creates SF Account + eligibility case, sends confirmation email * Creates SF Account + eligibility case, sends confirmation email

View File

@ -63,11 +63,8 @@ export default function ServicesPage() {
</div> </div>
</section> </section>
{/* All Services - Clean Grid */} {/* All Services - Clean Grid with staggered animations */}
<section <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">
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" }}
>
<ServiceCard <ServiceCard
href="/services/internet" href="/services/internet"
icon={<Wifi className="h-6 w-6" />} icon={<Wifi className="h-6 w-6" />}

View File

@ -235,7 +235,11 @@ export function JapanAddressForm({
const roomNumberOk = const roomNumberOk =
address.residenceType !== RESIDENCE_TYPE.APARTMENT || (address.roomNumber?.trim() ?? "") !== ""; 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 // Notify parent of changes - only send valid typed address when residenceType is set
useEffect(() => { useEffect(() => {
@ -622,25 +626,24 @@ export function JapanAddressForm({
{showSuccess ? <CheckCircle2 className="w-4 h-4" /> : "4"} {showSuccess ? <CheckCircle2 className="w-4 h-4" /> : "4"}
</div> </div>
<span className="text-sm font-medium text-foreground">Building Details</span> <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> </div>
{/* Building Name */} {/* Building Name */}
<FormField <FormField
label="Building Name" label="Building Name"
error={getError("buildingName")} error={getError("buildingName")}
required={false} required
helperText={ helperText={
isApartment ? "e.g., Sunshine Mansion (サンシャインマンション)" : "Optional" isApartment
? "e.g., Sunshine Mansion (サンシャインマンション)"
: "e.g., Tanaka Residence (田中邸)"
} }
> >
<Input <Input
value={address.buildingName ?? ""} value={address.buildingName ?? ""}
onChange={e => handleBuildingNameChange(e.target.value)} onChange={e => handleBuildingNameChange(e.target.value)}
onBlur={() => onBlur?.("buildingName")} onBlur={() => onBlur?.("buildingName")}
placeholder={isApartment ? "Sunshine Mansion" : "Building name (optional)"} placeholder={isApartment ? "Sunshine Mansion" : "Tanaka Residence"}
disabled={disabled} disabled={disabled}
data-field="address.buildingName" data-field="address.buildingName"
/> />

View File

@ -182,7 +182,6 @@ export function DashboardView() {
{/* Bottom Section: Quick Stats + Recent Activity */} {/* Bottom Section: Quick Stats + Recent Activity */}
<section <section
className="grid grid-cols-1 lg:grid-cols-2 gap-6 cp-stagger-children" className="grid grid-cols-1 lg:grid-cols-2 gap-6 cp-stagger-children"
style={{ animationDelay: "200ms" }}
aria-label="Account overview" aria-label="Account overview"
> >
<QuickStats <QuickStats
@ -190,14 +189,11 @@ export function DashboardView() {
openCases={summary?.stats?.openCases ?? 0} openCases={summary?.stats?.openCases ?? 0}
recentOrders={summary?.stats?.recentOrders} recentOrders={summary?.stats?.recentOrders}
isLoading={summaryLoading} isLoading={summaryLoading}
className="animate-in fade-in slide-in-from-bottom-6 duration-600"
/> />
<ActivityFeed <ActivityFeed
activities={summary?.recentActivity || []} activities={summary?.recentActivity || []}
maxItems={5} maxItems={5}
isLoading={summaryLoading} isLoading={summaryLoading}
className="animate-in fade-in slide-in-from-bottom-6 duration-600"
style={{ animationDelay: "100ms" }}
/> />
</section> </section>
</PageLayout> </PageLayout>

View File

@ -9,6 +9,7 @@ import {
sendVerificationCodeResponseSchema, sendVerificationCodeResponseSchema,
verifyCodeResponseSchema, verifyCodeResponseSchema,
quickEligibilityResponseSchema, quickEligibilityResponseSchema,
guestEligibilityResponseSchema,
maybeLaterResponseSchema, maybeLaterResponseSchema,
type SendVerificationCodeRequest, type SendVerificationCodeRequest,
type SendVerificationCodeResponse, type SendVerificationCodeResponse,
@ -16,6 +17,8 @@ import {
type VerifyCodeResponse, type VerifyCodeResponse,
type QuickEligibilityRequest, type QuickEligibilityRequest,
type QuickEligibilityResponse, type QuickEligibilityResponse,
type GuestEligibilityRequest,
type GuestEligibilityResponse,
type CompleteAccountRequest, type CompleteAccountRequest,
type MaybeLaterRequest, type MaybeLaterRequest,
type MaybeLaterResponse, 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( export async function quickEligibilityCheck(
request: QuickEligibilityRequest request: QuickEligibilityRequest
@ -61,6 +64,22 @@ export async function quickEligibilityCheck(
return quickEligibilityResponseSchema.parse(data); 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 * Maybe later flow - create SF account and eligibility case
*/ */

View File

@ -55,9 +55,16 @@ interface GetStartedFormProps {
export function GetStartedForm({ onStepChange }: GetStartedFormProps) { export function GetStartedForm({ onStepChange }: GetStartedFormProps) {
const { step, reset } = useGetStartedStore(); 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(() => { 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]); }, [reset]);
// Notify parent of step changes // Notify parent of step changes

View File

@ -19,6 +19,7 @@ import {
DocumentCheckIcon, DocumentCheckIcon,
UserPlusIcon, UserPlusIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { CheckCircle2 } from "lucide-react";
import { useGetStartedStore } from "../../../stores/get-started.store"; import { useGetStartedStore } from "../../../stores/get-started.store";
export function AccountStatusStep() { export function AccountStatusStep() {
@ -82,30 +83,70 @@ export function AccountStatusStep() {
// SF exists but not mapped - complete account with pre-filled data // SF exists but not mapped - complete account with pre-filled data
if (accountStatus === "sf_unmapped") { if (accountStatus === "sf_unmapped") {
return ( return (
<div className="space-y-6 text-center"> <div className="space-y-6">
<div className="flex justify-center"> <div className="text-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center"> <div className="flex justify-center">
<DocumentCheckIcon className="h-8 w-8 text-primary" /> <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> </div>
<div className="space-y-2"> {/* Show what's pre-filled vs what's needed */}
<h3 className="text-lg font-semibold text-foreground"> <div className="p-4 rounded-xl bg-muted/50 border border-border text-left space-y-3">
{prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"} <div>
</h3> <p className="text-xs font-medium text-muted-foreground mb-2">What we have:</p>
<p className="text-sm text-muted-foreground"> <ul className="space-y-1.5">
We found your information from a previous inquiry. Just set a password to complete your <li className="flex items-center gap-2 text-sm">
account setup. <CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
</p> <span>Name and email verified</span>
{prefill?.eligibilityStatus && ( </li>
<p className="text-sm font-medium text-success"> {prefill?.address && (
Eligibility Status: {prefill.eligibilityStatus} <li className="flex items-center gap-2 text-sm">
</p> <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&apos;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> </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"> <Button onClick={() => goToStep("complete-account")} className="w-full h-11">
Complete My Account Continue
<ArrowRightIcon className="h-4 w-4 ml-2" /> <ArrowRightIcon className="h-4 w-4 ml-2" />
</Button> </Button>
</div> </div>

View File

@ -63,6 +63,9 @@ export interface GetStartedState {
// Prefill data from existing account // Prefill data from existing account
prefill: VerifyCodeResponse["prefill"] | null; prefill: VerifyCodeResponse["prefill"] | null;
// Handoff token from eligibility check (for pre-filling data after OTP verification)
handoffToken: string | null;
// Loading and error states // Loading and error states
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@ -88,6 +91,7 @@ export interface GetStartedState {
setAccountStatus: (status: AccountStatus) => void; setAccountStatus: (status: AccountStatus) => void;
setPrefill: (prefill: VerifyCodeResponse["prefill"]) => void; setPrefill: (prefill: VerifyCodeResponse["prefill"]) => void;
setSessionToken: (token: string | null) => void; setSessionToken: (token: string | null) => void;
setHandoffToken: (token: string | null) => void;
// Reset // Reset
reset: () => void; reset: () => void;
@ -115,6 +119,7 @@ const initialState = {
accountStatus: null, accountStatus: null,
formData: initialFormData, formData: initialFormData,
prefill: null, prefill: null,
handoffToken: null,
loading: false, loading: false,
error: null, error: null,
codeSent: false, codeSent: false,
@ -155,9 +160,12 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const { handoffToken } = get();
const result = await api.verifyCode({ const result = await api.verifyCode({
email: get().formData.email, email: get().formData.email,
code, code,
// Pass handoff token if available (from eligibility check flow)
...(handoffToken && { handoffToken }),
}); });
if (result.verified && result.sessionToken && result.accountStatus) { if (result.verified && result.sessionToken && result.accountStatus) {
@ -264,6 +272,10 @@ export const useGetStartedStore = create<GetStartedState>()((set, get) => ({
set({ sessionToken: token, emailVerified: token !== null }); set({ sessionToken: token, emailVerified: token !== null });
}, },
setHandoffToken: (token: string | null) => {
set({ handoffToken: token });
},
reset: () => { reset: () => {
set(initialState); set(initialState);
}, },

View File

@ -1,9 +1,18 @@
/** /**
* GetStartedView - Main view for the get-started flow * GetStartedView - Main view for the get-started flow
* *
* Supports handoff from eligibility check flow: * Supports multiple handoff scenarios from eligibility check:
* - URL params: ?email=xxx&verified=true *
* - SessionStorage: get-started-email, get-started-verified * 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"; "use client";
@ -12,44 +21,131 @@ import { useState, useCallback, useEffect } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { AuthLayout } from "@/components/templates/AuthLayout"; import { AuthLayout } from "@/components/templates/AuthLayout";
import { GetStartedForm } from "../components"; 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() { export function GetStartedView() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { updateFormData, goToStep, setAccountStatus, setPrefill, setSessionToken } = const {
useGetStartedStore(); updateFormData,
goToStep,
setHandoffToken,
setSessionToken,
setAccountStatus,
setPrefill,
} = useGetStartedStore();
const [meta, setMeta] = useState({ const [meta, setMeta] = useState({
title: "Get Started", title: "Get Started",
subtitle: "Enter your email to begin", subtitle: "Enter your email to begin",
}); });
const [initialized, setInitialized] = useState(false); 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 // Check for handoff from eligibility check on mount
useEffect(() => { useEffect(() => {
if (initialized) return; if (initialized) return;
// Get params from URL or sessionStorage // Check for verified handoff (user already completed OTP on eligibility page)
const emailParam = searchParams.get("email");
const verifiedParam = searchParams.get("verified"); const verifiedParam = searchParams.get("verified");
const storedEmail = sessionStorage.getItem("get-started-email"); if (verifiedParam === "true") {
const storedVerified = sessionStorage.getItem("get-started-verified"); // 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-email");
sessionStorage.removeItem("get-started-verified");
const email = emailParam || storedEmail; const email = emailParam || storedEmail;
const isVerified = verifiedParam === "true" || storedVerified === "true"; const isHandoff = handoffParam === "true" || !!storedHandoffToken;
if (email && isVerified) { if (email && isHandoff) {
// User came from eligibility check - they have a verified email and SF Account // 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 }); 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); setInitialized(true);
@ -57,10 +153,11 @@ export function GetStartedView() {
initialized, initialized,
searchParams, searchParams,
updateFormData, updateFormData,
setHandoffToken,
goToStep, goToStep,
setSessionToken,
setAccountStatus, setAccountStatus,
setPrefill, setPrefill,
setSessionToken,
]); ]);
const handleStepChange = useCallback( const handleStepChange = useCallback(

View File

@ -159,12 +159,11 @@ export function PublicLandingView() {
</section> </section>
{/* ===== OUR SERVICES ===== */} {/* ===== OUR SERVICES ===== */}
<section <section id="services">
id="services" <div
className="animate-in fade-in slide-in-from-bottom-8 duration-700" className="text-center mb-10 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "600ms" }} style={{ animationDelay: "500ms" }}
> >
<div className="text-center mb-10">
<h2 className="text-display-sm font-display font-bold text-foreground mb-3"> <h2 className="text-display-sm font-display font-bold text-foreground mb-3">
Our Services Our Services
</h2> </h2>
@ -189,8 +188,8 @@ export function PublicLandingView() {
</div> </div>
</div> </div>
{/* Services Grid */} {/* 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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">
<ServiceCard <ServiceCard
href="/services/internet" href="/services/internet"
icon={<Wifi className="h-6 w-6" />} icon={<Wifi className="h-6 w-6" />}

View File

@ -339,11 +339,8 @@ export function SimPlansContent({
</div> </div>
)} )}
{/* Service Highlights */} {/* Service Highlights - uses cp-stagger-children internally */}
<section <section>
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<ServiceHighlights features={simFeatures} /> <ServiceHighlights features={simFeatures} />
</section> </section>

View File

@ -3,28 +3,30 @@
* *
* Flow: * Flow:
* 1. Enter name, email, address * 1. Enter name, email, address
* 2. Verify email with OTP * 2. Submit:
* 3. Creates SF Account + Case immediately on verification * - "Send Request Only" Success page (with "Create Account" CTA)
* 4. Shows options: "Create Account Now" or "Maybe Later" * - "Continue to Create Account" Inline OTP Redirect to complete-account
* 3. SF Account + Case created immediately on submit
*/ */
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; 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 { Button, Input, Label } from "@/components/atoms";
import { logger } from "@/core/logger";
import { import {
JapanAddressForm, JapanAddressForm,
type JapanAddressFormData, type JapanAddressFormData,
} from "@/features/address/components/JapanAddressForm"; } from "@/features/address/components/JapanAddressForm";
import { OtpInput } from "@/features/get-started/components/OtpInput/OtpInput";
import { import {
guestEligibilityCheck,
sendVerificationCode, sendVerificationCode,
verifyCode, verifyCode,
quickEligibilityCheck,
} from "@/features/get-started/api/get-started.api"; } 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 { prepareWhmcsAddressFields } from "@customer-portal/domain/address";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
@ -56,7 +58,7 @@ interface FormErrors {
} }
// ============================================================================ // ============================================================================
// Step Components // Form Step Component
// ============================================================================ // ============================================================================
interface FormStepProps { interface FormStepProps {
@ -68,7 +70,8 @@ interface FormStepProps {
onFormDataChange: (data: Partial<FormData>) => void; onFormDataChange: (data: Partial<FormData>) => void;
onAddressChange: (data: JapanAddressFormData, isComplete: boolean) => void; onAddressChange: (data: JapanAddressFormData, isComplete: boolean) => void;
onClearError: (field: keyof FormErrors) => void; onClearError: (field: keyof FormErrors) => void;
onSubmit: () => void; onSubmitOnly: () => void;
onSubmitAndCreate: () => void;
} }
function FormStep({ function FormStep({
@ -79,7 +82,8 @@ function FormStep({
onFormDataChange, onFormDataChange,
onAddressChange, onAddressChange,
onClearError, onClearError,
onSubmit, onSubmitOnly,
onSubmitAndCreate,
}: FormStepProps) { }: FormStepProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -139,6 +143,9 @@ function FormStep({
disabled={loading} disabled={loading}
error={formErrors.email} error={formErrors.email}
/> />
<p className="text-xs text-muted-foreground">
We&apos;ll send availability results to this email
</p>
{formErrors.email && <p className="text-sm text-danger">{formErrors.email}</p>} {formErrors.email && <p className="text-sm text-danger">{formErrors.email}</p>}
</div> </div>
@ -158,102 +165,160 @@ function FormStep({
</div> </div>
)} )}
{/* Submit */} {/* Two submission options */}
<Button <div className="space-y-3 pt-2">
type="button" <Button
onClick={onSubmit} type="button"
disabled={loading} onClick={onSubmitAndCreate}
loading={loading} disabled={loading}
className="w-full h-12" loading={loading}
> className="w-full h-12"
{loading ? "Sending Code..." : "Continue"} >
{!loading && <ArrowRight className="h-4 w-4 ml-2" />} <UserPlus className="h-4 w-4 mr-2" />
</Button> 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> </div>
); );
} }
// ============================================================================
// OTP Step Component
// ============================================================================
interface OtpStepProps { interface OtpStepProps {
email: string; email: string;
otpCode: string;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
attemptsRemaining: number | null; attemptsRemaining: number | null;
onOtpChange: (code: string) => void; resendDisabled: boolean;
resendCountdown: number;
onVerify: (code: string) => void; onVerify: (code: string) => void;
onResend: () => void; onResend: () => void;
onBack: () => void; onChangeEmail: () => void;
} }
function OtpStep({ function OtpStep({
email, email,
otpCode,
loading, loading,
error, error,
attemptsRemaining, attemptsRemaining,
onOtpChange, resendDisabled,
resendCountdown,
onVerify, onVerify,
onResend, onResend,
onBack, onChangeEmail,
}: OtpStepProps) { }: OtpStepProps) {
const [otpValue, setOtpValue] = useState("");
const handleComplete = useCallback(
(code: string) => {
onVerify(code);
},
[onVerify]
);
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<div className="text-center space-y-4"> {/* Header */}
<div className="text-center space-y-3">
<div className="flex justify-center"> <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"> <div className="h-14 w-14 rounded-full bg-primary/10 flex items-center justify-center">
<Mail className="h-8 w-8 text-primary" /> <Mail className="h-7 w-7 text-primary" />
</div> </div>
</div> </div>
<div> <div>
<h2 className="text-xl font-semibold text-foreground mb-2">Check your email</h2> <h2 className="text-xl font-semibold text-foreground">Verify Your Email</h2>
<p className="text-sm text-muted-foreground">We sent a verification code to</p> <p className="text-sm text-muted-foreground mt-1">
<p className="font-medium text-foreground mt-1">{email}</p> We sent a 6-digit code to <span className="font-medium text-foreground">{email}</span>
</p>
</div> </div>
</div> </div>
{/* OTP Input */} {/* OTP Input */}
<div className="flex justify-center"> <div className="py-4">
<OtpInput <OtpInput
value={otpCode} length={6}
onChange={onOtpChange} value={otpValue}
onComplete={onVerify} onChange={setOtpValue}
onComplete={handleComplete}
disabled={loading} disabled={loading}
error={ error={error || undefined}
error && attemptsRemaining !== null autoFocus
? `${error} (${attemptsRemaining} attempts remaining)`
: (error ?? undefined)
}
/> />
</div> </div>
{/* Resend */} {/* Attempts remaining warning */}
<div className="text-center"> {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 <button
type="button" type="button"
onClick={onResend} onClick={onResend}
disabled={loading} disabled={loading || resendDisabled}
className="text-sm text-primary hover:underline disabled:opacity-50" className="text-sm text-primary hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
> >
Didn&apos;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> </button>
</div> </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> </div>
); );
} }
// ============================================================================
// Success Step Component
// ============================================================================
interface SuccessStepProps { interface SuccessStepProps {
email: string;
requestId: string | null; requestId: string | null;
onCreateAccount: () => void; onBackToPlans: () => void;
onMaybeLater: () => void;
} }
function SuccessStep({ requestId, onCreateAccount, onMaybeLater }: SuccessStepProps) { function SuccessStep({ email, requestId, onBackToPlans }: SuccessStepProps) {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div className="text-center space-y-4"> <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> <h2 className="text-2xl font-bold text-foreground mb-3">Request Submitted!</h2>
<p className="text-muted-foreground max-w-md mx-auto"> <p className="text-muted-foreground max-w-md mx-auto">
We&apos;re checking internet availability at your address. Our team will review this and We&apos;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> </p>
</div> </div>
{requestId && ( {requestId && (
@ -278,22 +343,35 @@ function SuccessStep({ requestId, onCreateAccount, onMaybeLater }: SuccessStepPr
{/* What's next */} {/* What's next */}
<div className="bg-muted/50 rounded-xl p-6 space-y-4"> <div className="bg-muted/50 rounded-xl p-6 space-y-4">
<h3 className="font-semibold text-foreground text-center"> <div className="flex items-center justify-center gap-2 text-muted-foreground">
What would you like to do next? <Clock className="h-5 w-5" />
</h3> <span className="text-sm">Check your email for updates</span>
<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> </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"> <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> </p>
</div> </div>
</div> </div>
@ -322,12 +400,25 @@ export function PublicEligibilityCheckView() {
const [isAddressComplete, setIsAddressComplete] = useState(false); const [isAddressComplete, setIsAddressComplete] = useState(false);
// OTP state // 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 [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 // Success state
const [requestId, setRequestId] = useState<string | null>(null); const [requestId, setRequestId] = useState<string | null>(null);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (resendTimerRef.current) {
clearInterval(resendTimerRef.current);
}
};
}, []);
// Handle form data changes // Handle form data changes
const handleFormDataChange = useCallback((data: Partial<FormData>) => { const handleFormDataChange = useCallback((data: Partial<FormData>) => {
setFormData(prev => ({ ...prev, ...data })); setFormData(prev => ({ ...prev, ...data }));
@ -375,54 +466,41 @@ export function PublicEligibilityCheckView() {
return Object.keys(errors).length === 0; return Object.keys(errors).length === 0;
}; };
// Submit form and send OTP // Start resend countdown timer
const handleFormSubmit = async () => { const startResendTimer = useCallback(() => {
if (!validateForm()) return; setResendDisabled(true);
setResendCountdown(60);
setLoading(true); if (resendTimerRef.current) {
setError(null); clearInterval(resendTimerRef.current);
try {
await sendVerificationCode({ email: formData.email });
setStep("otp");
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
} }
};
// Verify OTP and create SF Account resendTimerRef.current = setInterval(() => {
const handleVerifyOtp = async (code: string) => { setResendCountdown(prev => {
if (code.length !== 6) return; 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); setLoading(true);
setError(null); setError(null);
try { 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 whmcsAddress = formData.address ? prepareWhmcsAddressFields(formData.address) : null;
const eligibilityResult = await quickEligibilityCheck({ const result = await guestEligibilityCheck({
sessionToken: verifyResult.sessionToken, email: formData.email.trim(),
firstName: formData.firstName.trim(), firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(), lastName: formData.lastName.trim(),
address: { address: {
@ -433,71 +511,200 @@ export function PublicEligibilityCheckView() {
postcode: whmcsAddress?.postcode || "", postcode: whmcsAddress?.postcode || "",
country: "JP", country: "JP",
}, },
continueToAccount,
}); });
if (eligibilityResult.submitted) { if (!result.submitted) {
setRequestId(eligibilityResult.requestId || null); setError(result.message || "Failed to submit eligibility check");
setStep("success"); return null;
} else {
setError(eligibilityResult.message || "Failed to submit eligibility check");
} }
return result;
} catch (err) { } 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 { } finally {
setLoading(false); setLoading(false);
} }
}; };
// Handle resend OTP // Handle "Send Request Only" - submit and show success
const handleResendCode = async () => { 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); setLoading(true);
setError(null); setError(null);
setOtpCode("");
try { try {
await sendVerificationCode({ email: formData.email }); // 1. Submit eligibility check (creates SF Account + Case)
setAttemptsRemaining(null); 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) { } 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 { } finally {
setLoading(false); setLoading(false);
} }
}; };
// Handle "Create Account Now" - redirect to get-started with email // Handle OTP verification
const handleCreateAccount = () => { const handleVerifyOtp = async (code: string) => {
// Store email in sessionStorage for get-started page to pick up if (code.length !== 6) return;
sessionStorage.setItem("get-started-email", formData.email);
sessionStorage.setItem("get-started-verified", "true"); setLoading(true);
router.push(`/auth/get-started?email=${encodeURIComponent(formData.email)}&verified=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" // Handle OTP resend
const handleMaybeLater = () => { const handleResendOtp = async () => {
// SF Account already created during quickEligibilityCheck, just go back to plans 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`); router.push(`${servicesBasePath}/internet`);
}; };
// Handle back from OTP step // Step meta for header
const handleBackFromOtp = () => { const stepMeta: Record<
setStep("form"); Step,
setError(null); { title: string; description: string; icon: "form" | "otp" | "success" }
setOtpCode(""); > = {
};
// Step titles and descriptions
const stepMeta = {
form: { form: {
title: "Check Availability", title: "Check Availability",
description: "Enter your details to check if internet service is available at your address.", description: "Enter your details to check if internet service is available at your address.",
icon: "form",
}, },
otp: { otp: {
title: "Verify Email", 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: { success: {
title: "Request Submitted", title: "Request Submitted",
description: "Your availability check request has been 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="mt-8 mb-8 text-center">
<div className="flex justify-center mb-4"> <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"> <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" />} {stepMeta[step].icon === "form" && <MapPin className="h-7 w-7 text-primary" />}
{step === "otp" && <Mail className="h-7 w-7 text-primary" />} {stepMeta[step].icon === "otp" && <Mail className="h-7 w-7 text-primary" />}
{step === "success" && <CheckCircle className="h-7 w-7 text-success" />} {stepMeta[step].icon === "success" && <CheckCircle className="h-7 w-7 text-success" />}
</div> </div>
</div> </div>
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2"> <h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2">
@ -522,22 +729,6 @@ export function PublicEligibilityCheckView() {
</p> </p>
</div> </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 */} {/* Card */}
<div className="bg-card border border-border rounded-2xl p-6 sm:p-8 shadow-[var(--cp-shadow-1)]"> <div className="bg-card border border-border rounded-2xl p-6 sm:p-8 shadow-[var(--cp-shadow-1)]">
{/* Form Step */} {/* Form Step */}
@ -551,7 +742,8 @@ export function PublicEligibilityCheckView() {
onFormDataChange={handleFormDataChange} onFormDataChange={handleFormDataChange}
onAddressChange={handleAddressChange} onAddressChange={handleAddressChange}
onClearError={handleClearError} onClearError={handleClearError}
onSubmit={handleFormSubmit} onSubmitOnly={handleSubmitOnly}
onSubmitAndCreate={handleSubmitAndCreate}
/> />
)} )}
@ -559,23 +751,23 @@ export function PublicEligibilityCheckView() {
{step === "otp" && ( {step === "otp" && (
<OtpStep <OtpStep
email={formData.email} email={formData.email}
otpCode={otpCode}
loading={loading} loading={loading}
error={error} error={otpError}
attemptsRemaining={attemptsRemaining} attemptsRemaining={attemptsRemaining}
onOtpChange={setOtpCode} resendDisabled={resendDisabled}
resendCountdown={resendCountdown}
onVerify={handleVerifyOtp} onVerify={handleVerifyOtp}
onResend={handleResendCode} onResend={handleResendOtp}
onBack={handleBackFromOtp} onChangeEmail={handleChangeEmail}
/> />
)} )}
{/* Success Step */} {/* Success Step */}
{step === "success" && ( {step === "success" && (
<SuccessStep <SuccessStep
email={formData.email}
requestId={requestId} requestId={requestId}
onCreateAccount={handleCreateAccount} onBackToPlans={handleBackToPlans}
onMaybeLater={handleMaybeLater}
/> />
)} )}
</div> </div>

View File

@ -305,20 +305,17 @@ export function PublicInternetPlansContent({
</p> </p>
</div> </div>
{/* Service Highlights */} {/* Service Highlights - uses cp-stagger-children internally */}
<section <section>
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<ServiceHighlights features={INTERNET_FEATURES} /> <ServiceHighlights features={INTERNET_FEATURES} />
</section> </section>
{/* Connection types section */} {/* Connection types section */}
<section <section className="space-y-4">
className="space-y-4 animate-in fade-in slide-in-from-bottom-8 duration-700" <div
style={{ animationDelay: "400ms" }} className="text-center mb-6 animate-in fade-in slide-in-from-bottom-8 duration-700"
> style={{ animationDelay: "300ms" }}
<div className="text-center mb-6"> >
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2"> <p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
Choose Your Connection Choose Your Connection
</p> </p>

View File

@ -149,11 +149,8 @@ export function PublicVpnPlansView() {
</div> </div>
</div> </div>
{/* Service Highlights */} {/* Service Highlights - uses cp-stagger-children internally */}
<section <section>
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "400ms" }}
>
<ServiceHighlights features={VPN_FEATURES} /> <ServiceHighlights features={VPN_FEATURES} />
</section> </section>

View File

@ -34,19 +34,26 @@ For customers who want to check internet availability before creating an account
└─→ /services/internet/check-availability (dedicated page) └─→ /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 2: Choose action:
└─→ Step 3: SF Account + Case created immediately
├─→ "Create Account Now" → Redirect to /auth/get-started ├─→ "Send Request Only"
│ (email pre-verified, goes to complete-account) │ └─→ 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 └─→ "Continue to Create Account"
(SF Account created for agent to review) ├─→ 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 ## Account Status Routing
| Portal | WHMCS | Salesforce | Mapping | → Result | | Portal | WHMCS | Salesforce | Mapping | → Result |
@ -128,32 +135,73 @@ Key schemas:
## Handoff from Eligibility Check ## 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` When a user clicks "Continue to Create Account":
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 "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 ### Flow B: "Send Request Only" → Return Later
- SF Account already created during eligibility check (agent can review)
- User can return anytime and use the same email to continue 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 ## Testing Checklist
### Manual Testing ### Manual Testing
1. **New customer flow**: Enter new email → Verify OTP → Full signup form 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 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" 4. **Eligibility check - Send Request Only**:
5. **Return flow**: Customer returns, enters same email → Auto-links to SF account - Click "Check Availability" → Fill form → Click "Send Request Only"
6. **Mobile experience**: Test eligibility check page on mobile viewport - 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 ### Security Testing

View File

@ -17,6 +17,9 @@ import type {
verifyCodeResponseSchema, verifyCodeResponseSchema,
quickEligibilityRequestSchema, quickEligibilityRequestSchema,
quickEligibilityResponseSchema, quickEligibilityResponseSchema,
guestEligibilityRequestSchema,
guestEligibilityResponseSchema,
guestHandoffTokenSchema,
completeAccountRequestSchema, completeAccountRequestSchema,
maybeLaterRequestSchema, maybeLaterRequestSchema,
maybeLaterResponseSchema, maybeLaterResponseSchema,
@ -84,6 +87,7 @@ export type GetStartedErrorCode =
export type SendVerificationCodeRequest = z.infer<typeof sendVerificationCodeRequestSchema>; export type SendVerificationCodeRequest = z.infer<typeof sendVerificationCodeRequestSchema>;
export type VerifyCodeRequest = z.infer<typeof verifyCodeRequestSchema>; export type VerifyCodeRequest = z.infer<typeof verifyCodeRequestSchema>;
export type QuickEligibilityRequest = z.infer<typeof quickEligibilityRequestSchema>; export type QuickEligibilityRequest = z.infer<typeof quickEligibilityRequestSchema>;
export type GuestEligibilityRequest = z.infer<typeof guestEligibilityRequestSchema>;
export type CompleteAccountRequest = z.infer<typeof completeAccountRequestSchema>; export type CompleteAccountRequest = z.infer<typeof completeAccountRequestSchema>;
export type MaybeLaterRequest = z.infer<typeof maybeLaterRequestSchema>; 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 SendVerificationCodeResponse = z.infer<typeof sendVerificationCodeResponseSchema>;
export type VerifyCodeResponse = z.infer<typeof verifyCodeResponseSchema>; export type VerifyCodeResponse = z.infer<typeof verifyCodeResponseSchema>;
export type QuickEligibilityResponse = z.infer<typeof quickEligibilityResponseSchema>; export type QuickEligibilityResponse = z.infer<typeof quickEligibilityResponseSchema>;
export type GuestEligibilityResponse = z.infer<typeof guestEligibilityResponseSchema>;
export type MaybeLaterResponse = z.infer<typeof maybeLaterResponseSchema>; export type MaybeLaterResponse = z.infer<typeof maybeLaterResponseSchema>;
// ============================================================================
// Handoff Token Types
// ============================================================================
export type GuestHandoffToken = z.infer<typeof guestHandoffTokenSchema>;
// ============================================================================ // ============================================================================
// Session Types // Session Types
// ============================================================================ // ============================================================================

View File

@ -26,6 +26,9 @@ export {
type VerifyCodeResponse, type VerifyCodeResponse,
type QuickEligibilityRequest, type QuickEligibilityRequest,
type QuickEligibilityResponse, type QuickEligibilityResponse,
type GuestEligibilityRequest,
type GuestEligibilityResponse,
type GuestHandoffToken,
type CompleteAccountRequest, type CompleteAccountRequest,
type MaybeLaterRequest, type MaybeLaterRequest,
type MaybeLaterResponse, type MaybeLaterResponse,
@ -45,9 +48,13 @@ export {
verifyCodeRequestSchema, verifyCodeRequestSchema,
verifyCodeResponseSchema, verifyCodeResponseSchema,
accountStatusSchema, accountStatusSchema,
// Quick eligibility schemas // Quick eligibility schemas (OTP-verified)
quickEligibilityRequestSchema, quickEligibilityRequestSchema,
quickEligibilityResponseSchema, quickEligibilityResponseSchema,
// Guest eligibility schemas (no OTP required)
guestEligibilityRequestSchema,
guestEligibilityResponseSchema,
guestHandoffTokenSchema,
// Account completion schemas // Account completion schemas
completeAccountRequestSchema, completeAccountRequestSchema,
// Maybe later schemas // Maybe later schemas

View File

@ -52,6 +52,8 @@ export const otpCodeSchema = z
export const verifyCodeRequestSchema = z.object({ export const verifyCodeRequestSchema = z.object({
email: emailSchema, email: emailSchema,
code: otpCodeSchema, 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(), 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 // Account Completion Schemas
// ============================================================================ // ============================================================================