feat: enhance address handling and OTP verification flow
- Introduced parsing logic for Salesforce addresses in VerificationWorkflowService to improve address handling. - Updated CompleteAccountStep and VerificationStep components to utilize new address structure and improve user experience. - Enhanced OTP input handling in OtpStep and added expiry timer functionality for better user feedback. - Refactored PrefilledUserInfo and AddressFields components to accommodate new address data structure. - Added error handling improvements and ensured consistent state management across verification steps.
This commit is contained in:
parent
454fb29c85
commit
05d3467efc
@ -6,6 +6,7 @@ import * as argon2 from "argon2";
|
||||
import {
|
||||
type CompleteAccountRequest,
|
||||
type SignupWithEligibilityRequest,
|
||||
type GetStartedSession,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
import type { BilingualAddress } from "@customer-portal/domain/address";
|
||||
|
||||
@ -174,13 +175,10 @@ export class AccountCreationWorkflowService {
|
||||
|
||||
private async executeCreation(
|
||||
request: CompleteAccountRequest | SignupWithEligibilityRequest,
|
||||
session: {
|
||||
email: string;
|
||||
sfAccountId?: string | undefined;
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
address?: Record<string, unknown> | undefined;
|
||||
},
|
||||
session: Pick<
|
||||
GetStartedSession,
|
||||
"email" | "sfAccountId" | "firstName" | "lastName" | "address"
|
||||
>,
|
||||
withEligibility: boolean
|
||||
): Promise<AuthResultInternal | SignupWithEligibilityResult> {
|
||||
const { password, phone, dateOfBirth, gender } = request;
|
||||
@ -203,7 +201,10 @@ export class AccountCreationWorkflowService {
|
||||
// Resolve address and names based on path
|
||||
let finalFirstName: string;
|
||||
let finalLastName: string;
|
||||
let address: NonNullable<CompleteAccountRequest["address"]> | BilingualAddress;
|
||||
let address:
|
||||
| NonNullable<CompleteAccountRequest["address"]>
|
||||
| BilingualAddress
|
||||
| GetStartedSession["address"];
|
||||
|
||||
if (withEligibility) {
|
||||
const eligibilityRequest = request as SignupWithEligibilityRequest;
|
||||
@ -268,9 +269,9 @@ export class AccountCreationWorkflowService {
|
||||
|
||||
eligibilityRequestId = caseId;
|
||||
} else {
|
||||
// SF address write only for new customers with prefectureJa
|
||||
// SF address write for new customers and SF-only users who completed the address form
|
||||
const completeAddress = address as NonNullable<CompleteAccountRequest["address"]>;
|
||||
if (isNewCustomer && completeAddress.prefectureJa) {
|
||||
if (completeAddress.prefectureJa) {
|
||||
await safeOperation(
|
||||
async () =>
|
||||
this.addressWriter.writeToSalesforce(
|
||||
@ -416,7 +417,7 @@ export class AccountCreationWorkflowService {
|
||||
|
||||
private validateRequest(
|
||||
request: CompleteAccountRequest,
|
||||
session: { sfAccountId?: string | undefined }
|
||||
session: Pick<GetStartedSession, "sfAccountId">
|
||||
): void {
|
||||
const isNewCustomer = !session.sfAccountId;
|
||||
|
||||
@ -457,8 +458,8 @@ export class AccountCreationWorkflowService {
|
||||
|
||||
private resolveAddress(
|
||||
requestAddress: CompleteAccountRequest["address"] | undefined,
|
||||
sessionAddress: Record<string, unknown> | undefined
|
||||
): NonNullable<CompleteAccountRequest["address"]> {
|
||||
sessionAddress: GetStartedSession["address"] | undefined
|
||||
): NonNullable<CompleteAccountRequest["address"]> | GetStartedSession["address"] {
|
||||
const address = requestAddress ?? sessionAddress;
|
||||
|
||||
if (!address || !address.postcode) {
|
||||
@ -467,13 +468,13 @@ export class AccountCreationWorkflowService {
|
||||
);
|
||||
}
|
||||
|
||||
return address as NonNullable<CompleteAccountRequest["address"]>;
|
||||
return address;
|
||||
}
|
||||
|
||||
private resolveNames(
|
||||
firstName: string | undefined,
|
||||
lastName: string | undefined,
|
||||
session: { firstName?: string | undefined; lastName?: string | undefined }
|
||||
session: Pick<GetStartedSession, "firstName" | "lastName">
|
||||
): { finalFirstName: string; finalLastName: string } {
|
||||
const finalFirstName = firstName ?? session.firstName;
|
||||
const finalLastName = lastName ?? session.lastName;
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
type VerifyCodeRequest,
|
||||
type VerifyCodeResponse,
|
||||
} from "@customer-portal/domain/get-started";
|
||||
import { STREET_ADDRESS_PATTERN } from "@customer-portal/domain/address";
|
||||
|
||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
@ -214,20 +215,13 @@ export class VerificationWorkflowService {
|
||||
if (mapping) {
|
||||
return { status: ACCOUNT_STATUS.PORTAL_EXISTS };
|
||||
}
|
||||
const sfAddress = sfAccount.address ? this.parseSfAddress(sfAccount.address) : undefined;
|
||||
return {
|
||||
status: ACCOUNT_STATUS.SF_UNMAPPED,
|
||||
sfAccountId: sfAccount.id,
|
||||
...(sfAccount.firstName && { sfFirstName: sfAccount.firstName }),
|
||||
...(sfAccount.lastName && { sfLastName: sfAccount.lastName }),
|
||||
...(sfAccount.address && {
|
||||
sfAddress: {
|
||||
prefectureJa: sfAccount.address.state,
|
||||
cityJa: sfAccount.address.city,
|
||||
// MailingStreet contains townJa + streetAddress concatenated — store as-is
|
||||
streetAddress: sfAccount.address.address1,
|
||||
postcode: sfAccount.address.postcode,
|
||||
},
|
||||
}),
|
||||
...(sfAddress && { sfAddress }),
|
||||
};
|
||||
}
|
||||
|
||||
@ -304,4 +298,42 @@ export class VerificationWorkflowService {
|
||||
...(prefill?.eligibilityStatus && { eligibilityStatus: prefill.eligibilityStatus }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Salesforce address into domain fields.
|
||||
* MailingStreet (address1) contains townJa + streetAddress concatenated (e.g., "東麻布1-5-3").
|
||||
* Extracts the numeric chome-banchi-go suffix as streetAddress, remainder as townJa.
|
||||
* When parsing fails, returns what we have (prefecture, city, postcode) so the
|
||||
* frontend can pre-populate the address form and let the user fill in the rest.
|
||||
*/
|
||||
private parseSfAddress(address: {
|
||||
address1: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postcode: string;
|
||||
country: string;
|
||||
}): NonNullable<AccountStatusResult["sfAddress"]> {
|
||||
const result: NonNullable<AccountStatusResult["sfAddress"]> = {
|
||||
prefectureJa: address.state,
|
||||
cityJa: address.city,
|
||||
postcode: address.postcode,
|
||||
};
|
||||
|
||||
if (!address.address1) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const match = address.address1.match(new RegExp(`^(.+?)(${STREET_ADDRESS_PATTERN.source})$`));
|
||||
if (match?.[1] && match[2]) {
|
||||
result.townJa = match[1];
|
||||
result.streetAddress = match[2];
|
||||
} else {
|
||||
this.logger.warn(
|
||||
{ mailingStreet: address.address1 },
|
||||
"Could not parse street address from Salesforce MailingStreet — user will complete via form"
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,20 +19,19 @@ import {
|
||||
PasswordSection,
|
||||
useCompleteAccountForm,
|
||||
} from "./complete-account";
|
||||
import type { PrefillData } from "./complete-account/types";
|
||||
import type { GetStartedFormData } from "../../../machines/get-started.types";
|
||||
|
||||
function computeAccountFlags(
|
||||
accountStatus: string | null,
|
||||
prefill: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
address?: { address1?: string; city?: string; state?: string; postcode?: string };
|
||||
} | null
|
||||
) {
|
||||
function computeAccountFlags(accountStatus: string | null, prefill: PrefillData | null) {
|
||||
const isNewCustomer = accountStatus === "new_customer";
|
||||
const hasPrefill = !!(prefill?.firstName || prefill?.lastName);
|
||||
const addr = prefill?.address;
|
||||
const hasCompleteAddress = !!(addr?.address1 && addr?.city && addr?.state && addr?.postcode);
|
||||
const hasCompleteAddress = !!(
|
||||
addr?.postcode &&
|
||||
addr?.prefectureJa &&
|
||||
addr?.cityJa &&
|
||||
addr?.streetAddress
|
||||
);
|
||||
const isSfUnmappedIncomplete = accountStatus === "sf_unmapped" && !hasCompleteAddress;
|
||||
return {
|
||||
isNewCustomer,
|
||||
@ -100,6 +99,8 @@ export function CompleteAccountStep() {
|
||||
{isSfUnmappedIncomplete && (
|
||||
<AddressFields
|
||||
onAddressChange={form.handleAddressChange}
|
||||
initialValues={prefill?.address ? { postcode: prefill.address.postcode } : undefined}
|
||||
description="Your address on file is incomplete. Please provide your full address."
|
||||
errors={form.errors}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
@ -1,65 +1,200 @@
|
||||
/**
|
||||
* VerificationStep - Enter 6-digit OTP code
|
||||
*
|
||||
* Features:
|
||||
* - Auto-submit on complete
|
||||
* - 60s resend cooldown with countdown
|
||||
* - Code expiry countdown timer
|
||||
* - Error display that works for repeated identical errors
|
||||
* - Max 3 resend attempts before forcing restart
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { OtpInput } from "@/components/molecules";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
||||
|
||||
const RESEND_COOLDOWN_SECONDS = 60;
|
||||
const MAX_RESENDS = 3;
|
||||
|
||||
function formatTimeRemaining(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function useResendCooldown() {
|
||||
const [countdown, setCountdown] = useState(RESEND_COOLDOWN_SECONDS);
|
||||
const [resendCount, setResendCount] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [countdown > 0]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const triggerCooldown = useCallback(() => {
|
||||
setResendCount(prev => prev + 1);
|
||||
setCountdown(RESEND_COOLDOWN_SECONDS);
|
||||
}, []);
|
||||
|
||||
return { countdown, resendCount, triggerCooldown, maxedOut: resendCount >= MAX_RESENDS };
|
||||
}
|
||||
|
||||
function useExpiryTimer(codeExpiresAt: string | null) {
|
||||
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeExpiresAt) return;
|
||||
|
||||
const expiryTime = new Date(codeExpiresAt).getTime();
|
||||
if (Number.isNaN(expiryTime)) {
|
||||
setTimeRemaining(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const remaining = Math.max(0, Math.floor((expiryTime - Date.now()) / 1000));
|
||||
setTimeRemaining(remaining);
|
||||
};
|
||||
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [codeExpiresAt]);
|
||||
|
||||
const isExpired = timeRemaining !== null && timeRemaining <= 0;
|
||||
return { timeRemaining, isExpired };
|
||||
}
|
||||
|
||||
function ExpiryDisplay({
|
||||
timeRemaining,
|
||||
isExpired,
|
||||
}: {
|
||||
timeRemaining: number | null;
|
||||
isExpired: boolean;
|
||||
}) {
|
||||
if (isExpired) {
|
||||
return (
|
||||
<p className="text-sm text-danger text-center" role="alert">
|
||||
Code expired. Please request a new one.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (timeRemaining !== null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Code expires in {formatTimeRemaining(timeRemaining)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ResendButton({
|
||||
disabled,
|
||||
maxedOut,
|
||||
countdown,
|
||||
onClick,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
maxedOut: boolean;
|
||||
countdown: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
let label = "Resend code";
|
||||
if (maxedOut) label = "Max resends reached";
|
||||
else if (countdown > 0) label = `Resend in ${countdown}s`;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
disabled={disabled || maxedOut}
|
||||
className="text-sm"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationStep() {
|
||||
const { state, send } = useGetStartedMachine();
|
||||
|
||||
const loading = state.matches({ verification: "loading" });
|
||||
const machineError = state.context.error;
|
||||
const machineErrorId = state.context.errorId;
|
||||
const attemptsRemaining = state.context.attemptsRemaining;
|
||||
const email = state.context.formData.email;
|
||||
const codeExpiresAt = state.context.codeExpiresAt;
|
||||
|
||||
const [code, setCode] = useState("");
|
||||
const [resending, setResending] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
// Sync machine errors into local state so we can clear on typing,
|
||||
// and clear the input so the user can retry
|
||||
const { countdown, triggerCooldown, maxedOut } = useResendCooldown();
|
||||
const { timeRemaining, isExpired } = useExpiryTimer(codeExpiresAt);
|
||||
|
||||
const resendDisabled = countdown > 0 || loading;
|
||||
|
||||
// Sync machine errors into local state using errorId to detect changes
|
||||
useEffect(() => {
|
||||
if (machineError) {
|
||||
if (machineError && machineErrorId > 0) {
|
||||
setLocalError(machineError);
|
||||
setCode("");
|
||||
}
|
||||
}, [machineError]);
|
||||
}, [machineError, machineErrorId]);
|
||||
|
||||
const handleCodeChange = (value: string) => {
|
||||
const handleCodeChange = useCallback((value: string) => {
|
||||
setCode(value);
|
||||
if (localError) setLocalError(null);
|
||||
};
|
||||
setLocalError(null);
|
||||
}, []);
|
||||
|
||||
const handleComplete = (completedCode: string) => {
|
||||
if (!loading) {
|
||||
setCode(completedCode);
|
||||
send({ type: "VERIFY_CODE", code: completedCode });
|
||||
}
|
||||
};
|
||||
const handleComplete = useCallback(
|
||||
(completedCode: string) => {
|
||||
if (!loading && !isExpired) {
|
||||
setCode(completedCode);
|
||||
send({ type: "VERIFY_CODE", code: completedCode });
|
||||
}
|
||||
},
|
||||
[loading, isExpired, send]
|
||||
);
|
||||
|
||||
const handleVerify = () => {
|
||||
if (code.length === 6) {
|
||||
const handleVerify = useCallback(() => {
|
||||
if (code.length === 6 && !isExpired) {
|
||||
send({ type: "VERIFY_CODE", code });
|
||||
}
|
||||
};
|
||||
}, [code, isExpired, send]);
|
||||
|
||||
const handleResend = () => {
|
||||
setResending(true);
|
||||
const handleResend = useCallback(() => {
|
||||
if (resendDisabled || maxedOut) return;
|
||||
setCode("");
|
||||
setLocalError(null);
|
||||
triggerCooldown();
|
||||
send({ type: "SEND_CODE", email });
|
||||
// Reset resending state after a short delay (the machine handles the actual async)
|
||||
setTimeout(() => setResending(false), 2000);
|
||||
};
|
||||
}, [resendDisabled, maxedOut, email, send, triggerCooldown]);
|
||||
|
||||
const handleGoBack = () => {
|
||||
const handleGoBack = useCallback(() => {
|
||||
send({ type: "RESET" });
|
||||
send({ type: "START" });
|
||||
};
|
||||
}, [send]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -72,7 +207,7 @@ export function VerificationStep() {
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
onComplete={handleComplete}
|
||||
disabled={loading}
|
||||
disabled={loading || isExpired}
|
||||
error={localError ?? undefined}
|
||||
autoFocus
|
||||
/>
|
||||
@ -83,11 +218,13 @@ export function VerificationStep() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleVerify}
|
||||
disabled={loading || code.length !== 6}
|
||||
disabled={loading || code.length !== 6 || isExpired}
|
||||
loading={loading}
|
||||
className="w-full h-11"
|
||||
>
|
||||
@ -105,21 +242,14 @@ export function VerificationStep() {
|
||||
Change email
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
<ResendButton
|
||||
disabled={resendDisabled}
|
||||
maxedOut={maxedOut}
|
||||
countdown={countdown}
|
||||
onClick={handleResend}
|
||||
disabled={loading || resending}
|
||||
className="text-sm"
|
||||
>
|
||||
{resending ? "Sending..." : "Resend code"}
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
The code expires in 10 minutes. Check your spam folder if you don't see it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,11 +4,14 @@ import { Label } from "@/components/atoms";
|
||||
import {
|
||||
JapanAddressForm,
|
||||
type JapanAddressFormData,
|
||||
type JapanAddressFormProps,
|
||||
} from "@/features/address/components/JapanAddressForm";
|
||||
import type { AccountFormErrors } from "./types";
|
||||
|
||||
interface AddressFieldsProps {
|
||||
onAddressChange: (data: JapanAddressFormData, isComplete: boolean) => void;
|
||||
initialValues?: JapanAddressFormProps["initialValues"];
|
||||
description?: string;
|
||||
errors: AccountFormErrors;
|
||||
loading: boolean;
|
||||
}
|
||||
@ -18,17 +21,26 @@ interface AddressFieldsProps {
|
||||
*
|
||||
* Used when an SF-unmapped user has incomplete prefilled address data
|
||||
* and needs to provide a full address before account creation.
|
||||
* Accepts initialValues to pre-populate from Salesforce (e.g., postcode).
|
||||
*/
|
||||
export function AddressFields({ onAddressChange, errors, loading }: AddressFieldsProps) {
|
||||
export function AddressFields({
|
||||
onAddressChange,
|
||||
initialValues,
|
||||
description = "Please provide your full address.",
|
||||
errors,
|
||||
loading,
|
||||
}: AddressFieldsProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Address <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your address information is incomplete. Please provide your full address.
|
||||
</p>
|
||||
<JapanAddressForm onChange={onAddressChange} disabled={loading} />
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
<JapanAddressForm
|
||||
initialValues={initialValues}
|
||||
onChange={onAddressChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.address && <p className="text-sm text-danger">{errors.address}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -11,14 +11,14 @@ interface PrefilledUserInfoProps {
|
||||
export function PrefilledUserInfo({ prefill, email }: PrefilledUserInfoProps) {
|
||||
const addressDisplay = prefill.address
|
||||
? [
|
||||
prefill.address.postcode,
|
||||
prefill.address.state,
|
||||
prefill.address.city,
|
||||
prefill.address.address1,
|
||||
prefill.address.address2,
|
||||
prefill.address.postcode && `〒${prefill.address.postcode}`,
|
||||
prefill.address.prefectureJa,
|
||||
prefill.address.cityJa,
|
||||
prefill.address.townJa,
|
||||
prefill.address.streetAddress,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
.join(" ")
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { VerifyCodeResponse } from "@customer-portal/domain/get-started";
|
||||
|
||||
export interface AccountFormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
@ -22,17 +24,4 @@ export interface AccountFormErrors {
|
||||
acceptTerms?: string | undefined;
|
||||
}
|
||||
|
||||
export interface PrefillData {
|
||||
firstName?: string | undefined;
|
||||
lastName?: string | undefined;
|
||||
phone?: string | undefined;
|
||||
address?:
|
||||
| {
|
||||
postcode?: string | undefined;
|
||||
state?: string | undefined;
|
||||
city?: string | undefined;
|
||||
address1?: string | undefined;
|
||||
address2?: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
export type PrefillData = NonNullable<VerifyCodeResponse["prefill"]>;
|
||||
|
||||
@ -80,7 +80,9 @@ export const getStartedMachine = setup({
|
||||
redirectTo: context.redirectTo,
|
||||
inline: context.inline,
|
||||
error: null,
|
||||
errorId: 0,
|
||||
codeSent: false,
|
||||
codeExpiresAt: null,
|
||||
attemptsRemaining: null,
|
||||
authResponse: null,
|
||||
})),
|
||||
@ -98,7 +100,9 @@ export const getStartedMachine = setup({
|
||||
redirectTo: null,
|
||||
inline: input?.inline ?? false,
|
||||
error: null,
|
||||
errorId: 0,
|
||||
codeSent: false,
|
||||
codeExpiresAt: null,
|
||||
attemptsRemaining: null,
|
||||
authResponse: null,
|
||||
}),
|
||||
@ -194,12 +198,14 @@ export const getStartedMachine = setup({
|
||||
actions: assign({
|
||||
codeSent: true,
|
||||
error: null,
|
||||
codeExpiresAt: () => new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => event.output.message,
|
||||
errorId: ({ context }) => context.errorId + 1,
|
||||
}),
|
||||
},
|
||||
],
|
||||
@ -207,6 +213,7 @@ export const getStartedMachine = setup({
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => getErrorMessage(event.error),
|
||||
errorId: ({ context }) => context.errorId + 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@ -273,6 +280,7 @@ export const getStartedMachine = setup({
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => event.output.error ?? "Verification failed",
|
||||
errorId: ({ context }) => context.errorId + 1,
|
||||
attemptsRemaining: ({ event }) => event.output.attemptsRemaining ?? null,
|
||||
}),
|
||||
},
|
||||
@ -281,6 +289,7 @@ export const getStartedMachine = setup({
|
||||
target: "error",
|
||||
actions: assign({
|
||||
error: ({ event }) => getErrorMessage(event.error),
|
||||
errorId: ({ context }) => context.errorId + 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@ -55,7 +55,11 @@ export interface GetStartedContext {
|
||||
redirectTo: string | null;
|
||||
inline: boolean;
|
||||
error: string | null;
|
||||
/** Monotonic counter so repeated identical errors still trigger re-renders */
|
||||
errorId: number;
|
||||
codeSent: boolean;
|
||||
/** ISO timestamp when the current OTP code expires (10 min from send) */
|
||||
codeExpiresAt: string | null;
|
||||
attemptsRemaining: number | null;
|
||||
authResponse: AuthResponse | null;
|
||||
}
|
||||
|
||||
@ -1,27 +1,99 @@
|
||||
/**
|
||||
* OtpStep - Enter 6-digit OTP verification code
|
||||
*
|
||||
* Features:
|
||||
* - Auto-submit on complete
|
||||
* - 60s resend cooldown with countdown (via store)
|
||||
* - Code expiry countdown timer
|
||||
* - Max 3 resend attempts before forcing restart
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/atoms";
|
||||
import { OtpInput } from "@/components/molecules";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store";
|
||||
|
||||
const MAX_RESENDS = 3;
|
||||
const CODE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function formatTimeRemaining(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function useExpiryTimer() {
|
||||
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
|
||||
const expiryRef = useRef<number>(Date.now() + CODE_TTL_MS);
|
||||
|
||||
const resetExpiry = useCallback(() => {
|
||||
expiryRef.current = Date.now() + CODE_TTL_MS;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const remaining = Math.max(0, Math.floor((expiryRef.current - Date.now()) / 1000));
|
||||
setTimeRemaining(remaining);
|
||||
};
|
||||
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const isExpired = timeRemaining !== null && timeRemaining <= 0;
|
||||
return { timeRemaining, isExpired, resetExpiry };
|
||||
}
|
||||
|
||||
function ExpiryDisplay({
|
||||
timeRemaining,
|
||||
isExpired,
|
||||
}: {
|
||||
timeRemaining: number | null;
|
||||
isExpired: boolean;
|
||||
}) {
|
||||
if (isExpired) {
|
||||
return (
|
||||
<p className="text-sm text-danger text-center" role="alert">
|
||||
Code expired. Please request a new one.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (timeRemaining !== null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Code expires in {formatTimeRemaining(timeRemaining)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function OtpActions({
|
||||
loading,
|
||||
resendDisabled,
|
||||
resendCountdown,
|
||||
resendCount,
|
||||
onChangeEmail,
|
||||
onResend,
|
||||
}: {
|
||||
loading: boolean;
|
||||
resendDisabled: boolean;
|
||||
resendCountdown: number;
|
||||
resendCount: number;
|
||||
onChangeEmail: () => void;
|
||||
onResend: () => void;
|
||||
}) {
|
||||
const maxedOut = resendCount >= MAX_RESENDS;
|
||||
|
||||
let resendLabel = "Resend code";
|
||||
if (maxedOut) resendLabel = "Max resends reached";
|
||||
else if (resendDisabled && resendCountdown > 0) resendLabel = `Resend in ${resendCountdown}s`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
@ -38,10 +110,10 @@ function OtpActions({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onResend}
|
||||
disabled={loading || resendDisabled}
|
||||
disabled={loading || resendDisabled || maxedOut}
|
||||
className="text-sm"
|
||||
>
|
||||
{resendDisabled && resendCountdown > 0 ? `Resend in ${resendCountdown}s` : "Resend code"}
|
||||
{resendLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@ -62,6 +134,17 @@ export function OtpStep() {
|
||||
} = useEligibilityCheckStore();
|
||||
|
||||
const [otpValue, setOtpValue] = useState("");
|
||||
const [resendCount, setResendCount] = useState(0);
|
||||
const { timeRemaining, isExpired, resetExpiry } = useExpiryTimer();
|
||||
|
||||
// Track error changes to clear OTP input on each new error
|
||||
const prevErrorRef = useRef(otpError);
|
||||
useEffect(() => {
|
||||
if (otpError && otpError !== prevErrorRef.current) {
|
||||
setOtpValue("");
|
||||
}
|
||||
prevErrorRef.current = otpError;
|
||||
}, [otpError]);
|
||||
|
||||
const handleCodeChange = useCallback(
|
||||
(value: string) => {
|
||||
@ -73,24 +156,28 @@ export function OtpStep() {
|
||||
|
||||
const handleComplete = useCallback(
|
||||
async (code: string) => {
|
||||
if (isExpired) return;
|
||||
try {
|
||||
await verifyOtp(code);
|
||||
} catch {
|
||||
setOtpValue("");
|
||||
}
|
||||
},
|
||||
[verifyOtp]
|
||||
[verifyOtp, isExpired]
|
||||
);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (otpValue.length === 6) {
|
||||
await verifyOtp(otpValue);
|
||||
}
|
||||
const handleVerify = () => {
|
||||
if (otpValue.length === 6 && !isExpired) void handleComplete(otpValue);
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (resendCount >= MAX_RESENDS) return;
|
||||
setOtpValue("");
|
||||
await resendOtp();
|
||||
const success = await resendOtp();
|
||||
if (success) {
|
||||
setResendCount(prev => prev + 1);
|
||||
resetExpiry();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -106,7 +193,7 @@ export function OtpStep() {
|
||||
value={otpValue}
|
||||
onChange={handleCodeChange}
|
||||
onComplete={handleComplete}
|
||||
disabled={loading}
|
||||
disabled={loading || isExpired}
|
||||
{...(otpError && { error: otpError })}
|
||||
autoFocus
|
||||
/>
|
||||
@ -118,10 +205,12 @@ export function OtpStep() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleVerify}
|
||||
disabled={loading || otpValue.length !== 6}
|
||||
disabled={loading || otpValue.length !== 6 || isExpired}
|
||||
loading={loading}
|
||||
className="w-full h-11"
|
||||
>
|
||||
@ -132,13 +221,10 @@ export function OtpStep() {
|
||||
loading={loading}
|
||||
resendDisabled={resendDisabled}
|
||||
resendCountdown={resendCountdown}
|
||||
resendCount={resendCount}
|
||||
onChangeEmail={() => goToStep("form")}
|
||||
onResend={handleResend}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
The code expires in 10 minutes. Check your spam folder if you don't see it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
image.png
BIN
image.png
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 182 KiB |
@ -24,6 +24,18 @@ export const WHMCS_ADDRESS_LIMITS = {
|
||||
STATE_MAX: 100,
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Street Address Pattern
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Regex pattern for Japanese street address (chome-banchi-go).
|
||||
* Matches: "1-5-3", "1-5", "15-3", "99-999-999"
|
||||
* Used in both input validation (streetAddressDetailSchema) and
|
||||
* parsing (extracting street number from concatenated SF MailingStreet).
|
||||
*/
|
||||
export const STREET_ADDRESS_PATTERN = /\d{1,2}-\d{1,3}(-\d{1,3})?/;
|
||||
|
||||
// ============================================================================
|
||||
// Input Field Limits
|
||||
// ============================================================================
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
// Constants
|
||||
export { RESIDENCE_TYPE, ADDRESS_LOOKUP_PROVIDER } from "./contract.js";
|
||||
export type { ResidenceType, AddressLookupProvider } from "./contract.js";
|
||||
export { ADDRESS_INPUT_LIMITS, WHMCS_ADDRESS_LIMITS } from "./constants.js";
|
||||
export { ADDRESS_INPUT_LIMITS, STREET_ADDRESS_PATTERN, WHMCS_ADDRESS_LIMITS } from "./constants.js";
|
||||
|
||||
// Schemas (includes derived types)
|
||||
export {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { truncate } from "../toolkit/formatting/text.js";
|
||||
import { ADDRESS_INPUT_LIMITS, WHMCS_ADDRESS_LIMITS } from "./constants.js";
|
||||
import { ADDRESS_INPUT_LIMITS, STREET_ADDRESS_PATTERN, WHMCS_ADDRESS_LIMITS } from "./constants.js";
|
||||
|
||||
// ============================================================================
|
||||
// ZIP Code Schemas
|
||||
@ -79,7 +79,7 @@ export const streetAddressDetailSchema = z
|
||||
.min(1, "Street address is required")
|
||||
.max(20, "Street address is too long")
|
||||
.regex(
|
||||
/^\d{1,2}-\d{1,3}(-\d{1,3})?$/,
|
||||
new RegExp(`^${STREET_ADDRESS_PATTERN.source}$`),
|
||||
"Use format like 1-5-3 (chome-banchi-go) or 1-5 (chome-banchi)"
|
||||
);
|
||||
|
||||
|
||||
@ -15,6 +15,25 @@ import {
|
||||
} from "../common/schema.js";
|
||||
import { bilingualAddressSchema } from "../address/schema.js";
|
||||
|
||||
/**
|
||||
* Loose address schema for prefill data from external systems (Salesforce).
|
||||
* Unlike bilingualAddressSchema, this does NOT apply input-validation rules
|
||||
* (e.g., street address regex) since the data was not entered through our form.
|
||||
*/
|
||||
const prefillAddressSchema = z.object({
|
||||
postcode: z.string().optional(),
|
||||
prefecture: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
town: z.string().optional(),
|
||||
prefectureJa: z.string().optional(),
|
||||
cityJa: z.string().optional(),
|
||||
townJa: z.string().optional(),
|
||||
streetAddress: z.string().optional(),
|
||||
buildingName: z.string().optional().nullable(),
|
||||
roomNumber: z.string().optional().nullable(),
|
||||
residenceType: z.enum(["house", "apartment"]).optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Validation Message Constants
|
||||
// ============================================================================
|
||||
@ -95,7 +114,7 @@ export const verifyCodeResponseSchema = z.object({
|
||||
lastName: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
address: bilingualAddressSchema.partial().optional(),
|
||||
address: prefillAddressSchema.optional(),
|
||||
eligibilityStatus: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
@ -304,8 +323,8 @@ export const getStartedSessionSchema = z.object({
|
||||
firstName: z.string().optional(),
|
||||
/** Last name (if provided during quick check) */
|
||||
lastName: z.string().optional(),
|
||||
/** Address (if provided during quick check) */
|
||||
address: bilingualAddressSchema.partial().optional(),
|
||||
/** Address (if provided during quick check or prefilled from SF) */
|
||||
address: prefillAddressSchema.optional(),
|
||||
/** Phone number (if provided) */
|
||||
phone: z.string().optional(),
|
||||
/** Account status after verification */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user