From 1d1602f5e7df4e91008e930ae12033453961fd32 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 14 Jan 2026 17:14:07 +0900 Subject: [PATCH] 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. --- .../infra/otp/get-started-session.service.ts | 90 ++- .../workflows/get-started-workflow.service.ts | 135 +++++ .../http/get-started.controller.ts | 26 + .../src/app/(public)/(site)/services/page.tsx | 7 +- .../address/components/JapanAddressForm.tsx | 17 +- .../dashboard/views/DashboardView.tsx | 4 - .../get-started/api/get-started.api.ts | 21 +- .../GetStartedForm/GetStartedForm.tsx | 11 +- .../steps/AccountStatusStep.tsx | 77 ++- .../get-started/stores/get-started.store.ts | 12 + .../get-started/views/GetStartedView.tsx | 139 ++++- .../landing-page/views/PublicLandingView.tsx | 15 +- .../components/sim/SimPlansContent.tsx | 7 +- .../services/views/PublicEligibilityCheck.tsx | 530 ++++++++++++------ .../services/views/PublicInternetPlans.tsx | 17 +- .../services/views/PublicVpnPlans.tsx | 7 +- docs/features/unified-get-started-flow.md | 96 +++- packages/domain/get-started/contract.ts | 11 + packages/domain/get-started/index.ts | 9 +- packages/domain/get-started/schema.ts | 68 +++ 20 files changed, 1018 insertions(+), 281 deletions(-) diff --git a/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts b/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts index b00fe870..50dec56c 100644 --- a/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts +++ b/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts @@ -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 { @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 { + 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 { + const data = await this.cache.get(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 { + 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); diff --git a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts index 4bdbc4fe..8e47526e 100644 --- a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts @@ -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 { + 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 { + const appBase = this.config.get("APP_BASE_URL", "http://localhost:3000"); + const templateId = this.config.get("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: ` +

Hi ${firstName},

+

We received your request to check internet availability.

+

We'll review this and email you the results within 1-2 business days.

+

To create an account and view your request status, visit: ${appBase}/auth/get-started

+

Reference ID: ${requestId}

+ `, + }); + } + } + private async determineAccountStatus( email: string ): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { diff --git a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts index c990fc7b..fcf97c3c 100644 --- a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts @@ -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 { + const fingerprint = getRequestFingerprint(req); + return this.workflow.guestEligibilityCheck(body, fingerprint); + } + /** * "Maybe Later" flow * Creates SF Account + eligibility case, sends confirmation email diff --git a/apps/portal/src/app/(public)/(site)/services/page.tsx b/apps/portal/src/app/(public)/(site)/services/page.tsx index b90594d8..2870ad51 100644 --- a/apps/portal/src/app/(public)/(site)/services/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/page.tsx @@ -63,11 +63,8 @@ export default function ServicesPage() { - {/* All Services - Clean Grid */} -
+ {/* All Services - Clean Grid with staggered animations */} +
} diff --git a/apps/portal/src/features/address/components/JapanAddressForm.tsx b/apps/portal/src/features/address/components/JapanAddressForm.tsx index 0b9ba15e..e5a7132e 100644 --- a/apps/portal/src/features/address/components/JapanAddressForm.tsx +++ b/apps/portal/src/features/address/components/JapanAddressForm.tsx @@ -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 ? : "4"} Building Details - {!isApartment && ( - Optional for houses - )} {/* Building Name */} 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" /> diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index bd37cf56..fbea01a1 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -182,7 +182,6 @@ export function DashboardView() { {/* Bottom Section: Quick Stats + Recent Activity */}
diff --git a/apps/portal/src/features/get-started/api/get-started.api.ts b/apps/portal/src/features/get-started/api/get-started.api.ts index a18236ac..0778999f 100644 --- a/apps/portal/src/features/get-started/api/get-started.api.ts +++ b/apps/portal/src/features/get-started/api/get-started.api.ts @@ -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 { + const response = await apiClient.POST( + `${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 */ diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx index 73dff369..5df9cb11 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx @@ -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 diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx index 75f28907..fcd43663 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx @@ -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 ( -
-
-
- +
+
+
+
+ +
+
+ +
+

+ {prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"} +

+

+ We found your information from a previous inquiry. Complete a few more details to + activate your account. +

-
-

- {prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"} -

-

- We found your information from a previous inquiry. Just set a password to complete your - account setup. -

- {prefill?.eligibilityStatus && ( -

- Eligibility Status: {prefill.eligibilityStatus} -

- )} + {/* Show what's pre-filled vs what's needed */} +
+
+

What we have:

+
    +
  • + + Name and email verified +
  • + {prefill?.address && ( +
  • + + Address from your inquiry +
  • + )} +
+
+ +
+

What you'll add:

+
    +
  • + + Phone number +
  • +
  • + + Date of birth +
  • +
  • + + Password +
  • +
+
+ {prefill?.eligibilityStatus && ( +

+ Eligibility Status: {prefill.eligibilityStatus} +

+ )} +
diff --git a/apps/portal/src/features/get-started/stores/get-started.store.ts b/apps/portal/src/features/get-started/stores/get-started.store.ts index 587b598c..352c9fd3 100644 --- a/apps/portal/src/features/get-started/stores/get-started.store.ts +++ b/apps/portal/src/features/get-started/stores/get-started.store.ts @@ -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()((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()((set, get) => ({ set({ sessionToken: token, emailVerified: token !== null }); }, + setHandoffToken: (token: string | null) => { + set({ handoffToken: token }); + }, + reset: () => { set(initialState); }, diff --git a/apps/portal/src/features/get-started/views/GetStartedView.tsx b/apps/portal/src/features/get-started/views/GetStartedView.tsx index 38c1c38a..250620a4 100644 --- a/apps/portal/src/features/get-started/views/GetStartedView.tsx +++ b/apps/portal/src/features/get-started/views/GetStartedView.tsx @@ -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( diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 89784cb3..ee052483 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -159,12 +159,11 @@ export function PublicLandingView() {
{/* ===== OUR SERVICES ===== */} -
-
+
+

Our Services

@@ -189,8 +188,8 @@ export function PublicLandingView() {
- {/* Services Grid */} -
+ {/* Services Grid - uses cp-stagger-children for consistent card animations */} +
} diff --git a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx index f20a9f70..671e4edd 100644 --- a/apps/portal/src/features/services/components/sim/SimPlansContent.tsx +++ b/apps/portal/src/features/services/components/sim/SimPlansContent.tsx @@ -339,11 +339,8 @@ export function SimPlansContent({
)} - {/* Service Highlights */} -
+ {/* Service Highlights - uses cp-stagger-children internally */} +
diff --git a/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx b/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx index 768b93c4..01e844c0 100644 --- a/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx +++ b/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx @@ -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) => 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 (
@@ -139,6 +143,9 @@ function FormStep({ disabled={loading} error={formErrors.email} /> +

+ We'll send availability results to this email +

{formErrors.email &&

{formErrors.email}

}
@@ -158,102 +165,160 @@ function FormStep({
)} - {/* Submit */} - + {/* Two submission options */} +
+ + + + +

+ You can create your account anytime later using the same email address. +

+
); } +// ============================================================================ +// 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 ( -
-
+
+ {/* Header */} +
-
- +
+
-

Check your email

-

We sent a verification code to

-

{email}

+

Verify Your Email

+

+ We sent a 6-digit code to {email} +

{/* OTP Input */} -
+
- {/* Resend */} -
+ {/* Attempts remaining warning */} + {attemptsRemaining !== null && attemptsRemaining < 3 && ( +

+ {attemptsRemaining} attempt{attemptsRemaining !== 1 ? "s" : ""} remaining +

+ )} + + {/* Verify Button */} + + + {/* Actions */} +
+ +
- - {/* Back */} -
); } +// ============================================================================ +// 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 (
@@ -266,7 +331,7 @@ function SuccessStep({ requestId, onCreateAccount, onMaybeLater }: SuccessStepPr

Request Submitted!

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.

{requestId && ( @@ -278,22 +343,35 @@ function SuccessStep({ requestId, onCreateAccount, onMaybeLater }: SuccessStepPr {/* What's next */}
-

- What would you like to do next? -

-
- - - +
+ + Check your email for updates
+ + + + {/* Divider */} +
+
+ +
+
+ or +
+
+ + {/* Create Account CTA */} + + + +

- You can create your account anytime using the same email address. + Creating an account lets you track your request and order services faster.

@@ -322,12 +400,25 @@ export function PublicEligibilityCheckView() { const [isAddressComplete, setIsAddressComplete] = useState(false); // OTP state - const [otpCode, setOtpCode] = useState(""); + const [handoffToken, setHandoffToken] = useState(null); + const [otpError, setOtpError] = useState(null); const [attemptsRemaining, setAttemptsRemaining] = useState(null); + const [resendDisabled, setResendDisabled] = useState(false); + const [resendCountdown, setResendCountdown] = useState(0); + const resendTimerRef = useRef | null>(null); // Success state const [requestId, setRequestId] = useState(null); + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (resendTimerRef.current) { + clearInterval(resendTimerRef.current); + } + }; + }, []); + // Handle form data changes const handleFormDataChange = useCallback((data: Partial) => { 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() {
- {step === "form" && } - {step === "otp" && } - {step === "success" && } + {stepMeta[step].icon === "form" && } + {stepMeta[step].icon === "otp" && } + {stepMeta[step].icon === "success" && }

@@ -522,22 +729,6 @@ export function PublicEligibilityCheckView() {

- {/* Progress indicator */} - {step !== "success" && ( -
- {["form", "otp"].map((s, i) => ( -
-
- {i < 1 &&
} -
- ))} -
- )} - {/* Card */}
{/* 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" && ( )} {/* Success Step */} {step === "success" && ( )}
diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index 3724259d..0c4653fd 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -305,20 +305,17 @@ export function PublicInternetPlansContent({

- {/* Service Highlights */} -
+ {/* Service Highlights - uses cp-stagger-children internally */} +
{/* Connection types section */} -
-
+
+

Choose Your Connection

diff --git a/apps/portal/src/features/services/views/PublicVpnPlans.tsx b/apps/portal/src/features/services/views/PublicVpnPlans.tsx index e2a9b5b2..aa27b038 100644 --- a/apps/portal/src/features/services/views/PublicVpnPlans.tsx +++ b/apps/portal/src/features/services/views/PublicVpnPlans.tsx @@ -149,11 +149,8 @@ export function PublicVpnPlansView() {
- {/* Service Highlights */} -
+ {/* Service Highlights - uses cp-stagger-children internally */} +
diff --git a/docs/features/unified-get-started-flow.md b/docs/features/unified-get-started-flow.md index b3fc2aed..9648672a 100644 --- a/docs/features/unified-get-started-flow.md +++ b/docs/features/unified-get-started-flow.md @@ -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 diff --git a/packages/domain/get-started/contract.ts b/packages/domain/get-started/contract.ts index 8e44cbae..f842928b 100644 --- a/packages/domain/get-started/contract.ts +++ b/packages/domain/get-started/contract.ts @@ -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; export type VerifyCodeRequest = z.infer; export type QuickEligibilityRequest = z.infer; +export type GuestEligibilityRequest = z.infer; export type CompleteAccountRequest = z.infer; export type MaybeLaterRequest = z.infer; @@ -94,8 +98,15 @@ export type MaybeLaterRequest = z.infer; export type SendVerificationCodeResponse = z.infer; export type VerifyCodeResponse = z.infer; export type QuickEligibilityResponse = z.infer; +export type GuestEligibilityResponse = z.infer; export type MaybeLaterResponse = z.infer; +// ============================================================================ +// Handoff Token Types +// ============================================================================ + +export type GuestHandoffToken = z.infer; + // ============================================================================ // Session Types // ============================================================================ diff --git a/packages/domain/get-started/index.ts b/packages/domain/get-started/index.ts index 2029e536..2b242d4d 100644 --- a/packages/domain/get-started/index.ts +++ b/packages/domain/get-started/index.ts @@ -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 diff --git a/packages/domain/get-started/schema.ts b/packages/domain/get-started/schema.ts index 94f643a1..98011927 100644 --- a/packages/domain/get-started/schema.ts +++ b/packages/domain/get-started/schema.ts @@ -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 // ============================================================================