diff --git a/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts
index a6877dc6..1b68ce8d 100644
--- a/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts
+++ b/apps/bff/src/modules/auth/infra/workflows/account-creation-workflow.service.ts
@@ -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 | undefined;
- },
+ session: Pick<
+ GetStartedSession,
+ "email" | "sfAccountId" | "firstName" | "lastName" | "address"
+ >,
withEligibility: boolean
): Promise {
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 | BilingualAddress;
+ let address:
+ | NonNullable
+ | 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;
- 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
): void {
const isNewCustomer = !session.sfAccountId;
@@ -457,8 +458,8 @@ export class AccountCreationWorkflowService {
private resolveAddress(
requestAddress: CompleteAccountRequest["address"] | undefined,
- sessionAddress: Record | undefined
- ): NonNullable {
+ sessionAddress: GetStartedSession["address"] | undefined
+ ): NonNullable | GetStartedSession["address"] {
const address = requestAddress ?? sessionAddress;
if (!address || !address.postcode) {
@@ -467,13 +468,13 @@ export class AccountCreationWorkflowService {
);
}
- return address as NonNullable;
+ return address;
}
private resolveNames(
firstName: string | undefined,
lastName: string | undefined,
- session: { firstName?: string | undefined; lastName?: string | undefined }
+ session: Pick
): { finalFirstName: string; finalLastName: string } {
const finalFirstName = firstName ?? session.firstName;
const finalLastName = lastName ?? session.lastName;
diff --git a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts
index 90c1b9f0..ff01135d 100644
--- a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts
+++ b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts
@@ -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 {
+ const result: NonNullable = {
+ 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;
+ }
}
diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx
index d9423157..bc626e6d 100644
--- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx
+++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx
@@ -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 && (
diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx
index c342a7b3..5dbab91e 100644
--- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx
+++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx
@@ -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 | 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(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 (
+
+ Code expired. Please request a new one.
+
+ );
+ }
+ if (timeRemaining !== null) {
+ return (
+
+
+ Code expires in {formatTimeRemaining(timeRemaining)}
+
+ );
+ }
+ 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 (
+
+ );
+}
+
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(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 (
@@ -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() {
)}
+
+
-
+ />
-
-
- The code expires in 10 minutes. Check your spam folder if you don't see it.
-
);
}
diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/AddressFields.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/AddressFields.tsx
index 34a14601..aa3349b1 100644
--- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/AddressFields.tsx
+++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/AddressFields.tsx
@@ -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 (
-
- Your address information is incomplete. Please provide your full address.
-
-
+
{description}
+
{errors.address &&
{errors.address}
}
);
diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PrefilledUserInfo.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PrefilledUserInfo.tsx
index dcd6cb3c..5f65c0da 100644
--- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PrefilledUserInfo.tsx
+++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/PrefilledUserInfo.tsx
@@ -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 (
diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/types.ts b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/types.ts
index 342b5051..5cae5e3d 100644
--- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/types.ts
+++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/complete-account/types.ts
@@ -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;
diff --git a/apps/portal/src/features/get-started/machines/get-started.machine.ts b/apps/portal/src/features/get-started/machines/get-started.machine.ts
index 0697a6db..69437219 100644
--- a/apps/portal/src/features/get-started/machines/get-started.machine.ts
+++ b/apps/portal/src/features/get-started/machines/get-started.machine.ts
@@ -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,
}),
},
},
diff --git a/apps/portal/src/features/get-started/machines/get-started.types.ts b/apps/portal/src/features/get-started/machines/get-started.types.ts
index 4c35af9c..16c8c003 100644
--- a/apps/portal/src/features/get-started/machines/get-started.types.ts
+++ b/apps/portal/src/features/get-started/machines/get-started.types.ts
@@ -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;
}
diff --git a/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx b/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx
index cb4266c9..38d79b7c 100644
--- a/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx
+++ b/apps/portal/src/features/services/components/eligibility-check/steps/OtpStep.tsx
@@ -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(null);
+ const expiryRef = useRef(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 (
+
+ Code expired. Please request a new one.
+
+ );
+ }
+ if (timeRemaining !== null) {
+ return (
+
+
+ Code expires in {formatTimeRemaining(timeRemaining)}
+
+ );
+ }
+ 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 (
);
@@ -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() {
)}
+
+