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 {
|
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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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't see it.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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't see it.
|
|
||||||
</p>
|
|
||||||
</div>
|
</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,
|
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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user