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:
barsa 2026-03-07 11:12:11 +09:00
parent 454fb29c85
commit 05d3467efc
15 changed files with 413 additions and 118 deletions

View File

@ -6,6 +6,7 @@ import * as argon2 from "argon2";
import { import {
type CompleteAccountRequest, type CompleteAccountRequest,
type SignupWithEligibilityRequest, type SignupWithEligibilityRequest,
type GetStartedSession,
} from "@customer-portal/domain/get-started"; } from "@customer-portal/domain/get-started";
import type { BilingualAddress } from "@customer-portal/domain/address"; import type { BilingualAddress } from "@customer-portal/domain/address";
@ -174,13 +175,10 @@ export class AccountCreationWorkflowService {
private async executeCreation( private async executeCreation(
request: CompleteAccountRequest | SignupWithEligibilityRequest, request: CompleteAccountRequest | SignupWithEligibilityRequest,
session: { session: Pick<
email: string; GetStartedSession,
sfAccountId?: string | undefined; "email" | "sfAccountId" | "firstName" | "lastName" | "address"
firstName?: string | undefined; >,
lastName?: string | undefined;
address?: Record<string, unknown> | undefined;
},
withEligibility: boolean withEligibility: boolean
): Promise<AuthResultInternal | SignupWithEligibilityResult> { ): Promise<AuthResultInternal | SignupWithEligibilityResult> {
const { password, phone, dateOfBirth, gender } = request; const { password, phone, dateOfBirth, gender } = request;
@ -203,7 +201,10 @@ export class AccountCreationWorkflowService {
// Resolve address and names based on path // Resolve address and names based on path
let finalFirstName: string; let finalFirstName: string;
let finalLastName: string; let finalLastName: string;
let address: NonNullable<CompleteAccountRequest["address"]> | BilingualAddress; let address:
| NonNullable<CompleteAccountRequest["address"]>
| BilingualAddress
| GetStartedSession["address"];
if (withEligibility) { if (withEligibility) {
const eligibilityRequest = request as SignupWithEligibilityRequest; const eligibilityRequest = request as SignupWithEligibilityRequest;
@ -268,9 +269,9 @@ export class AccountCreationWorkflowService {
eligibilityRequestId = caseId; eligibilityRequestId = caseId;
} else { } 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"]>; const completeAddress = address as NonNullable<CompleteAccountRequest["address"]>;
if (isNewCustomer && completeAddress.prefectureJa) { if (completeAddress.prefectureJa) {
await safeOperation( await safeOperation(
async () => async () =>
this.addressWriter.writeToSalesforce( this.addressWriter.writeToSalesforce(
@ -416,7 +417,7 @@ export class AccountCreationWorkflowService {
private validateRequest( private validateRequest(
request: CompleteAccountRequest, request: CompleteAccountRequest,
session: { sfAccountId?: string | undefined } session: Pick<GetStartedSession, "sfAccountId">
): void { ): void {
const isNewCustomer = !session.sfAccountId; const isNewCustomer = !session.sfAccountId;
@ -457,8 +458,8 @@ export class AccountCreationWorkflowService {
private resolveAddress( private resolveAddress(
requestAddress: CompleteAccountRequest["address"] | undefined, requestAddress: CompleteAccountRequest["address"] | undefined,
sessionAddress: Record<string, unknown> | undefined sessionAddress: GetStartedSession["address"] | undefined
): NonNullable<CompleteAccountRequest["address"]> { ): NonNullable<CompleteAccountRequest["address"]> | GetStartedSession["address"] {
const address = requestAddress ?? sessionAddress; const address = requestAddress ?? sessionAddress;
if (!address || !address.postcode) { if (!address || !address.postcode) {
@ -467,13 +468,13 @@ export class AccountCreationWorkflowService {
); );
} }
return address as NonNullable<CompleteAccountRequest["address"]>; return address;
} }
private resolveNames( private resolveNames(
firstName: string | undefined, firstName: string | undefined,
lastName: string | undefined, lastName: string | undefined,
session: { firstName?: string | undefined; lastName?: string | undefined } session: Pick<GetStartedSession, "firstName" | "lastName">
): { finalFirstName: string; finalLastName: string } { ): { finalFirstName: string; finalLastName: string } {
const finalFirstName = firstName ?? session.firstName; const finalFirstName = firstName ?? session.firstName;
const finalLastName = lastName ?? session.lastName; const finalLastName = lastName ?? session.lastName;

View File

@ -9,6 +9,7 @@ import {
type VerifyCodeRequest, type VerifyCodeRequest,
type VerifyCodeResponse, type VerifyCodeResponse,
} from "@customer-portal/domain/get-started"; } 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 { UsersService } from "@bff/modules/users/application/users.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
@ -214,20 +215,13 @@ export class VerificationWorkflowService {
if (mapping) { if (mapping) {
return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; return { status: ACCOUNT_STATUS.PORTAL_EXISTS };
} }
const sfAddress = sfAccount.address ? this.parseSfAddress(sfAccount.address) : undefined;
return { return {
status: ACCOUNT_STATUS.SF_UNMAPPED, status: ACCOUNT_STATUS.SF_UNMAPPED,
sfAccountId: sfAccount.id, sfAccountId: sfAccount.id,
...(sfAccount.firstName && { sfFirstName: sfAccount.firstName }), ...(sfAccount.firstName && { sfFirstName: sfAccount.firstName }),
...(sfAccount.lastName && { sfLastName: sfAccount.lastName }), ...(sfAccount.lastName && { sfLastName: sfAccount.lastName }),
...(sfAccount.address && { ...(sfAddress && { sfAddress }),
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,
},
}),
}; };
} }
@ -304,4 +298,42 @@ export class VerificationWorkflowService {
...(prefill?.eligibilityStatus && { eligibilityStatus: prefill.eligibilityStatus }), ...(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;
}
} }

View File

@ -19,20 +19,19 @@ import {
PasswordSection, PasswordSection,
useCompleteAccountForm, useCompleteAccountForm,
} from "./complete-account"; } from "./complete-account";
import type { PrefillData } from "./complete-account/types";
import type { GetStartedFormData } from "../../../machines/get-started.types"; import type { GetStartedFormData } from "../../../machines/get-started.types";
function computeAccountFlags( function computeAccountFlags(accountStatus: string | null, prefill: PrefillData | null) {
accountStatus: string | null,
prefill: {
firstName?: string;
lastName?: string;
address?: { address1?: string; city?: string; state?: string; postcode?: string };
} | null
) {
const isNewCustomer = accountStatus === "new_customer"; const isNewCustomer = accountStatus === "new_customer";
const hasPrefill = !!(prefill?.firstName || prefill?.lastName); const hasPrefill = !!(prefill?.firstName || prefill?.lastName);
const addr = prefill?.address; 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; const isSfUnmappedIncomplete = accountStatus === "sf_unmapped" && !hasCompleteAddress;
return { return {
isNewCustomer, isNewCustomer,
@ -100,6 +99,8 @@ export function CompleteAccountStep() {
{isSfUnmappedIncomplete && ( {isSfUnmappedIncomplete && (
<AddressFields <AddressFields
onAddressChange={form.handleAddressChange} 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} errors={form.errors}
loading={loading} loading={loading}
/> />

View File

@ -1,65 +1,200 @@
/** /**
* VerificationStep - Enter 6-digit OTP code * 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"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/atoms"; import { Button } from "@/components/atoms";
import { OtpInput } from "@/components/molecules"; import { OtpInput } from "@/components/molecules";
import { Clock } from "lucide-react";
import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine"; 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() { export function VerificationStep() {
const { state, send } = useGetStartedMachine(); const { state, send } = useGetStartedMachine();
const loading = state.matches({ verification: "loading" }); const loading = state.matches({ verification: "loading" });
const machineError = state.context.error; const machineError = state.context.error;
const machineErrorId = state.context.errorId;
const attemptsRemaining = state.context.attemptsRemaining; const attemptsRemaining = state.context.attemptsRemaining;
const email = state.context.formData.email; const email = state.context.formData.email;
const codeExpiresAt = state.context.codeExpiresAt;
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [resending, setResending] = useState(false);
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
// Sync machine errors into local state so we can clear on typing, const { countdown, triggerCooldown, maxedOut } = useResendCooldown();
// and clear the input so the user can retry const { timeRemaining, isExpired } = useExpiryTimer(codeExpiresAt);
const resendDisabled = countdown > 0 || loading;
// Sync machine errors into local state using errorId to detect changes
useEffect(() => { useEffect(() => {
if (machineError) { if (machineError && machineErrorId > 0) {
setLocalError(machineError); setLocalError(machineError);
setCode(""); setCode("");
} }
}, [machineError]); }, [machineError, machineErrorId]);
const handleCodeChange = (value: string) => { const handleCodeChange = useCallback((value: string) => {
setCode(value); setCode(value);
if (localError) setLocalError(null); setLocalError(null);
}; }, []);
const handleComplete = (completedCode: string) => { const handleComplete = useCallback(
if (!loading) { (completedCode: string) => {
setCode(completedCode); if (!loading && !isExpired) {
send({ type: "VERIFY_CODE", code: completedCode }); setCode(completedCode);
} send({ type: "VERIFY_CODE", code: completedCode });
}; }
},
[loading, isExpired, send]
);
const handleVerify = () => { const handleVerify = useCallback(() => {
if (code.length === 6) { if (code.length === 6 && !isExpired) {
send({ type: "VERIFY_CODE", code }); send({ type: "VERIFY_CODE", code });
} }
}; }, [code, isExpired, send]);
const handleResend = () => { const handleResend = useCallback(() => {
setResending(true); if (resendDisabled || maxedOut) return;
setCode(""); setCode("");
setLocalError(null);
triggerCooldown();
send({ type: "SEND_CODE", email }); send({ type: "SEND_CODE", email });
// Reset resending state after a short delay (the machine handles the actual async) }, [resendDisabled, maxedOut, email, send, triggerCooldown]);
setTimeout(() => setResending(false), 2000);
};
const handleGoBack = () => { const handleGoBack = useCallback(() => {
send({ type: "RESET" }); send({ type: "RESET" });
send({ type: "START" }); send({ type: "START" });
}; }, [send]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -72,7 +207,7 @@ export function VerificationStep() {
value={code} value={code}
onChange={handleCodeChange} onChange={handleCodeChange}
onComplete={handleComplete} onComplete={handleComplete}
disabled={loading} disabled={loading || isExpired}
error={localError ?? undefined} error={localError ?? undefined}
autoFocus autoFocus
/> />
@ -83,11 +218,13 @@ export function VerificationStep() {
</p> </p>
)} )}
<ExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} />
<div className="space-y-3"> <div className="space-y-3">
<Button <Button
type="button" type="button"
onClick={handleVerify} onClick={handleVerify}
disabled={loading || code.length !== 6} disabled={loading || code.length !== 6 || isExpired}
loading={loading} loading={loading}
className="w-full h-11" className="w-full h-11"
> >
@ -105,21 +242,14 @@ export function VerificationStep() {
Change email Change email
</Button> </Button>
<Button <ResendButton
type="button" disabled={resendDisabled}
variant="ghost" maxedOut={maxedOut}
countdown={countdown}
onClick={handleResend} onClick={handleResend}
disabled={loading || resending} />
className="text-sm"
>
{resending ? "Sending..." : "Resend code"}
</Button>
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground text-center">
The code expires in 10 minutes. Check your spam folder if you don&apos;t see it.
</p>
</div> </div>
); );
} }

View File

@ -4,11 +4,14 @@ import { Label } from "@/components/atoms";
import { import {
JapanAddressForm, JapanAddressForm,
type JapanAddressFormData, type JapanAddressFormData,
type JapanAddressFormProps,
} from "@/features/address/components/JapanAddressForm"; } from "@/features/address/components/JapanAddressForm";
import type { AccountFormErrors } from "./types"; import type { AccountFormErrors } from "./types";
interface AddressFieldsProps { interface AddressFieldsProps {
onAddressChange: (data: JapanAddressFormData, isComplete: boolean) => void; onAddressChange: (data: JapanAddressFormData, isComplete: boolean) => void;
initialValues?: JapanAddressFormProps["initialValues"];
description?: string;
errors: AccountFormErrors; errors: AccountFormErrors;
loading: boolean; loading: boolean;
} }
@ -18,17 +21,26 @@ interface AddressFieldsProps {
* *
* Used when an SF-unmapped user has incomplete prefilled address data * Used when an SF-unmapped user has incomplete prefilled address data
* and needs to provide a full address before account creation. * 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>
Address <span className="text-danger">*</span> Address <span className="text-danger">*</span>
</Label> </Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{description}</p>
Your address information is incomplete. Please provide your full address. <JapanAddressForm
</p> initialValues={initialValues}
<JapanAddressForm onChange={onAddressChange} disabled={loading} /> onChange={onAddressChange}
disabled={loading}
/>
{errors.address && <p className="text-sm text-danger">{errors.address}</p>} {errors.address && <p className="text-sm text-danger">{errors.address}</p>}
</div> </div>
); );

View File

@ -11,14 +11,14 @@ interface PrefilledUserInfoProps {
export function PrefilledUserInfo({ prefill, email }: PrefilledUserInfoProps) { export function PrefilledUserInfo({ prefill, email }: PrefilledUserInfoProps) {
const addressDisplay = prefill.address const addressDisplay = prefill.address
? [ ? [
prefill.address.postcode, prefill.address.postcode && `${prefill.address.postcode}`,
prefill.address.state, prefill.address.prefectureJa,
prefill.address.city, prefill.address.cityJa,
prefill.address.address1, prefill.address.townJa,
prefill.address.address2, prefill.address.streetAddress,
] ]
.filter(Boolean) .filter(Boolean)
.join(", ") .join(" ")
: null; : null;
return ( return (

View File

@ -1,3 +1,5 @@
import type { VerifyCodeResponse } from "@customer-portal/domain/get-started";
export interface AccountFormData { export interface AccountFormData {
firstName: string; firstName: string;
lastName: string; lastName: string;
@ -22,17 +24,4 @@ export interface AccountFormErrors {
acceptTerms?: string | undefined; acceptTerms?: string | undefined;
} }
export interface PrefillData { export type PrefillData = NonNullable<VerifyCodeResponse["prefill"]>;
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;
}

View File

@ -80,7 +80,9 @@ export const getStartedMachine = setup({
redirectTo: context.redirectTo, redirectTo: context.redirectTo,
inline: context.inline, inline: context.inline,
error: null, error: null,
errorId: 0,
codeSent: false, codeSent: false,
codeExpiresAt: null,
attemptsRemaining: null, attemptsRemaining: null,
authResponse: null, authResponse: null,
})), })),
@ -98,7 +100,9 @@ export const getStartedMachine = setup({
redirectTo: null, redirectTo: null,
inline: input?.inline ?? false, inline: input?.inline ?? false,
error: null, error: null,
errorId: 0,
codeSent: false, codeSent: false,
codeExpiresAt: null,
attemptsRemaining: null, attemptsRemaining: null,
authResponse: null, authResponse: null,
}), }),
@ -194,12 +198,14 @@ export const getStartedMachine = setup({
actions: assign({ actions: assign({
codeSent: true, codeSent: true,
error: null, error: null,
codeExpiresAt: () => new Date(Date.now() + 10 * 60 * 1000).toISOString(),
}), }),
}, },
{ {
target: "error", target: "error",
actions: assign({ actions: assign({
error: ({ event }) => event.output.message, error: ({ event }) => event.output.message,
errorId: ({ context }) => context.errorId + 1,
}), }),
}, },
], ],
@ -207,6 +213,7 @@ export const getStartedMachine = setup({
target: "error", target: "error",
actions: assign({ actions: assign({
error: ({ event }) => getErrorMessage(event.error), error: ({ event }) => getErrorMessage(event.error),
errorId: ({ context }) => context.errorId + 1,
}), }),
}, },
}, },
@ -273,6 +280,7 @@ export const getStartedMachine = setup({
target: "error", target: "error",
actions: assign({ actions: assign({
error: ({ event }) => event.output.error ?? "Verification failed", error: ({ event }) => event.output.error ?? "Verification failed",
errorId: ({ context }) => context.errorId + 1,
attemptsRemaining: ({ event }) => event.output.attemptsRemaining ?? null, attemptsRemaining: ({ event }) => event.output.attemptsRemaining ?? null,
}), }),
}, },
@ -281,6 +289,7 @@ export const getStartedMachine = setup({
target: "error", target: "error",
actions: assign({ actions: assign({
error: ({ event }) => getErrorMessage(event.error), error: ({ event }) => getErrorMessage(event.error),
errorId: ({ context }) => context.errorId + 1,
}), }),
}, },
}, },

View File

@ -55,7 +55,11 @@ export interface GetStartedContext {
redirectTo: string | null; redirectTo: string | null;
inline: boolean; inline: boolean;
error: string | null; error: string | null;
/** Monotonic counter so repeated identical errors still trigger re-renders */
errorId: number;
codeSent: boolean; codeSent: boolean;
/** ISO timestamp when the current OTP code expires (10 min from send) */
codeExpiresAt: string | null;
attemptsRemaining: number | null; attemptsRemaining: number | null;
authResponse: AuthResponse | null; authResponse: AuthResponse | null;
} }

View File

@ -1,27 +1,99 @@
/** /**
* OtpStep - Enter 6-digit OTP verification code * 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"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { Button } from "@/components/atoms"; import { Button } from "@/components/atoms";
import { OtpInput } from "@/components/molecules"; import { OtpInput } from "@/components/molecules";
import { Clock } from "lucide-react";
import { useEligibilityCheckStore } from "../../../stores/eligibility-check.store"; 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({ function OtpActions({
loading, loading,
resendDisabled, resendDisabled,
resendCountdown, resendCountdown,
resendCount,
onChangeEmail, onChangeEmail,
onResend, onResend,
}: { }: {
loading: boolean; loading: boolean;
resendDisabled: boolean; resendDisabled: boolean;
resendCountdown: number; resendCountdown: number;
resendCount: number;
onChangeEmail: () => void; onChangeEmail: () => void;
onResend: () => 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 ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
@ -38,10 +110,10 @@ function OtpActions({
type="button" type="button"
variant="ghost" variant="ghost"
onClick={onResend} onClick={onResend}
disabled={loading || resendDisabled} disabled={loading || resendDisabled || maxedOut}
className="text-sm" className="text-sm"
> >
{resendDisabled && resendCountdown > 0 ? `Resend in ${resendCountdown}s` : "Resend code"} {resendLabel}
</Button> </Button>
</div> </div>
); );
@ -62,6 +134,17 @@ export function OtpStep() {
} = useEligibilityCheckStore(); } = useEligibilityCheckStore();
const [otpValue, setOtpValue] = useState(""); 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( const handleCodeChange = useCallback(
(value: string) => { (value: string) => {
@ -73,24 +156,28 @@ export function OtpStep() {
const handleComplete = useCallback( const handleComplete = useCallback(
async (code: string) => { async (code: string) => {
if (isExpired) return;
try { try {
await verifyOtp(code); await verifyOtp(code);
} catch { } catch {
setOtpValue(""); setOtpValue("");
} }
}, },
[verifyOtp] [verifyOtp, isExpired]
); );
const handleVerify = async () => { const handleVerify = () => {
if (otpValue.length === 6) { if (otpValue.length === 6 && !isExpired) void handleComplete(otpValue);
await verifyOtp(otpValue);
}
}; };
const handleResend = async () => { const handleResend = async () => {
if (resendCount >= MAX_RESENDS) return;
setOtpValue(""); setOtpValue("");
await resendOtp(); const success = await resendOtp();
if (success) {
setResendCount(prev => prev + 1);
resetExpiry();
}
}; };
return ( return (
@ -106,7 +193,7 @@ export function OtpStep() {
value={otpValue} value={otpValue}
onChange={handleCodeChange} onChange={handleCodeChange}
onComplete={handleComplete} onComplete={handleComplete}
disabled={loading} disabled={loading || isExpired}
{...(otpError && { error: otpError })} {...(otpError && { error: otpError })}
autoFocus autoFocus
/> />
@ -118,10 +205,12 @@ export function OtpStep() {
</p> </p>
)} )}
<ExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} />
<Button <Button
type="button" type="button"
onClick={handleVerify} onClick={handleVerify}
disabled={loading || otpValue.length !== 6} disabled={loading || otpValue.length !== 6 || isExpired}
loading={loading} loading={loading}
className="w-full h-11" className="w-full h-11"
> >
@ -132,13 +221,10 @@ export function OtpStep() {
loading={loading} loading={loading}
resendDisabled={resendDisabled} resendDisabled={resendDisabled}
resendCountdown={resendCountdown} resendCountdown={resendCountdown}
resendCount={resendCount}
onChangeEmail={() => goToStep("form")} onChangeEmail={() => goToStep("form")}
onResend={handleResend} onResend={handleResend}
/> />
<p className="text-xs text-muted-foreground text-center">
The code expires in 10 minutes. Check your spam folder if you don&apos;t see it.
</p>
</div> </div>
); );
} }

BIN
image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 182 KiB

View File

@ -24,6 +24,18 @@ export const WHMCS_ADDRESS_LIMITS = {
STATE_MAX: 100, STATE_MAX: 100,
} as const; } 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 // Input Field Limits
// ============================================================================ // ============================================================================

View File

@ -11,7 +11,7 @@
// Constants // Constants
export { RESIDENCE_TYPE, ADDRESS_LOOKUP_PROVIDER } from "./contract.js"; export { RESIDENCE_TYPE, ADDRESS_LOOKUP_PROVIDER } from "./contract.js";
export type { ResidenceType, AddressLookupProvider } 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) // Schemas (includes derived types)
export { export {

View File

@ -7,7 +7,7 @@
import { z } from "zod"; import { z } from "zod";
import { truncate } from "../toolkit/formatting/text.js"; 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 // ZIP Code Schemas
@ -79,7 +79,7 @@ export const streetAddressDetailSchema = z
.min(1, "Street address is required") .min(1, "Street address is required")
.max(20, "Street address is too long") .max(20, "Street address is too long")
.regex( .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)" "Use format like 1-5-3 (chome-banchi-go) or 1-5 (chome-banchi)"
); );

View File

@ -15,6 +15,25 @@ import {
} from "../common/schema.js"; } from "../common/schema.js";
import { bilingualAddressSchema } from "../address/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 // Validation Message Constants
// ============================================================================ // ============================================================================
@ -95,7 +114,7 @@ export const verifyCodeResponseSchema = z.object({
lastName: z.string().optional(), lastName: z.string().optional(),
email: z.string().optional(), email: z.string().optional(),
phone: z.string().optional(), phone: z.string().optional(),
address: bilingualAddressSchema.partial().optional(), address: prefillAddressSchema.optional(),
eligibilityStatus: z.string().optional(), eligibilityStatus: z.string().optional(),
}) })
.optional(), .optional(),
@ -304,8 +323,8 @@ export const getStartedSessionSchema = z.object({
firstName: z.string().optional(), firstName: z.string().optional(),
/** Last name (if provided during quick check) */ /** Last name (if provided during quick check) */
lastName: z.string().optional(), lastName: z.string().optional(),
/** Address (if provided during quick check) */ /** Address (if provided during quick check or prefilled from SF) */
address: bilingualAddressSchema.partial().optional(), address: prefillAddressSchema.optional(),
/** Phone number (if provided) */ /** Phone number (if provided) */
phone: z.string().optional(), phone: z.string().optional(),
/** Account status after verification */ /** Account status after verification */