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() {

)} + +