feat: Implement unified eligibility check flow with inline OTP verification
- Refactor PublicLandingView to enhance service section animations. - Update SimPlansContent and PublicEligibilityCheck to streamline service highlights. - Revise PublicEligibilityCheck to support new flow: "Send Request Only" and "Continue to Create Account". - Introduce guest eligibility check API with handoff token for account creation. - Modify success step to provide clear options for account creation and navigation. - Enhance form handling and error management in PublicEligibilityCheckView. - Update domain schemas to accommodate guest eligibility requests and responses. - Document new eligibility check flows and testing procedures.
This commit is contained in:
parent
bb4be98444
commit
1d1602f5e7
@ -4,7 +4,11 @@ import { Injectable, Inject } from "@nestjs/common";
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { 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);
|
||||||
|
|||||||
@ -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 }> {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" />}
|
||||||
|
|||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'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>
|
||||||
|
|||||||
@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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" />}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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'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'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're checking internet availability at your address. Our team will review this and
|
We're checking internet availability at your address. Our team will review this and
|
||||||
get back to you within 1-2 business days.
|
email you the results within 1-2 business days.
|
||||||
</p>
|
</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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user