feat: enhance Salesforce account handling and OTP components

- Updated SalesforceAccountService to include dynamic portal source field in SOQL query for improved account retrieval.
- Modified VerificationWorkflowService to conditionally parse addresses based on portal source eligibility.
- Refactored OTP components to utilize OtpExpiryDisplay for better user feedback on expiration.
- Cleaned up unused code in LoginOtpStep and OtpStep components for improved clarity and performance.
- Enhanced CompleteAccountStep to remove unnecessary address prefill logic, streamlining the user experience.
This commit is contained in:
barsa 2026-03-07 11:37:57 +09:00
parent 05d3467efc
commit 18b4c515a4
9 changed files with 67 additions and 101 deletions

View File

@ -159,10 +159,11 @@ export class SalesforceAccountService {
try {
// Search for Contact with matching email and get the associated Account + Contact details
const result = (await this.connection.query(
`SELECT Account.Id, Account.SF_Account_No__c, FirstName, LastName, MailingStreet, MailingCity, MailingState, MailingPostalCode, MailingCountry FROM Contact WHERE Email = '${this.safeSoql(email)}' LIMIT 1`,
`SELECT Account.Id, Account.SF_Account_No__c, Account.${this.portalSourceField}, FirstName, LastName, MailingStreet, MailingCity, MailingState, MailingPostalCode, MailingCountry FROM Contact WHERE Email = '${this.safeSoql(email)}' LIMIT 1`,
{ label: "checkout:findAccountByEmail" }
)) as SalesforceResponse<{
Account: { Id: string; SF_Account_No__c: string };
// Index signature needed: portalSourceField is env-configurable
Account: Record<string, unknown> & { Id: string; SF_Account_No__c: string };
FirstName?: string | null;
LastName?: string | null;
MailingStreet?: string | null;
@ -174,12 +175,14 @@ export class SalesforceAccountService {
if (result.totalSize > 0 && result.records[0]?.Account) {
const record = result.records[0];
const portalSource = (record.Account[this.portalSourceField] as string | null) ?? undefined;
const hasAddress = record.MailingCity || record.MailingState || record.MailingPostalCode;
return {
id: record.Account.Id,
accountNumber: record.Account.SF_Account_No__c,
...(record.FirstName && { firstName: record.FirstName }),
...(record.LastName && { lastName: record.LastName }),
...(portalSource && { portalSource }),
...(hasAddress && {
address: {
address1: record.MailingStreet || "",
@ -560,6 +563,7 @@ export interface FindByEmailResult {
accountNumber: string;
firstName?: string;
lastName?: string;
portalSource?: string;
address?: {
address1: string;
city: string;

View File

@ -11,6 +11,8 @@ import {
} from "@customer-portal/domain/get-started";
import { STREET_ADDRESS_PATTERN } from "@customer-portal/domain/address";
import { PORTAL_SOURCE_INTERNET_ELIGIBILITY } from "@bff/modules/auth/constants/portal.constants.js";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
@ -215,7 +217,12 @@ export class VerificationWorkflowService {
if (mapping) {
return { status: ACCOUNT_STATUS.PORTAL_EXISTS };
}
const sfAddress = sfAccount.address ? this.parseSfAddress(sfAccount.address) : undefined;
// Only parse address for accounts created via internet eligibility —
// those have a known address format (townJa + chome-banchi-go).
// All other SF accounts get a blank address form.
const isFromEligibility = sfAccount.portalSource === PORTAL_SOURCE_INTERNET_ELIGIBILITY;
const sfAddress =
isFromEligibility && sfAccount.address ? this.parseSfAddress(sfAccount.address) : undefined;
return {
status: ACCOUNT_STATUS.SF_UNMAPPED,
sfAccountId: sfAccount.id,

View File

@ -0,0 +1,34 @@
"use client";
import { Clock } from "lucide-react";
export function formatTimeRemaining(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export function OtpExpiryDisplay({
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;
}

View File

@ -1 +1,2 @@
export { OtpInput } from "./OtpInput";
export { OtpExpiryDisplay, formatTimeRemaining } from "./OtpExpiryDisplay";

View File

@ -10,7 +10,7 @@ export type { DataTableProps, Column } from "./DataTable/DataTable";
// Form components
export { FormField } from "./FormField/FormField";
export type { FormFieldProps } from "./FormField/FormField";
export { OtpInput } from "./OtpInput";
export { OtpInput, OtpExpiryDisplay, formatTimeRemaining } from "./OtpInput";
export { SearchFilterBar } from "./SearchFilterBar/SearchFilterBar";
export type { SearchFilterBarProps, FilterOption } from "./SearchFilterBar/SearchFilterBar";

View File

@ -9,7 +9,7 @@
import { useState, useCallback, useEffect } from "react";
import { Button, ErrorMessage } from "@/components/atoms";
import { OtpInput } from "@/components/molecules";
import { OtpInput, formatTimeRemaining } from "@/components/molecules";
import { ArrowLeft, Mail, Clock } from "lucide-react";
interface LoginOtpStepProps {
@ -23,15 +23,6 @@ interface LoginOtpStepProps {
error?: string | null;
}
/**
* Format remaining time as MM:SS
*/
function formatTimeRemaining(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export function LoginOtpStep({
sessionToken: _sessionToken,
maskedEmail,

View File

@ -99,8 +99,6 @@ export function CompleteAccountStep() {
{isSfUnmappedIncomplete && (
<AddressFields
onAddressChange={form.handleAddressChange}
initialValues={prefill?.address ? { postcode: prefill.address.postcode } : undefined}
description="Your address on file is incomplete. Please provide your full address."
errors={form.errors}
loading={loading}
/>

View File

@ -11,50 +11,41 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/atoms";
import { OtpInput } from "@/components/molecules";
import { Clock } from "lucide-react";
import { OtpInput, OtpExpiryDisplay } from "@/components/molecules";
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);
// Re-run timer whenever resendCount changes (initial mount + each resend)
useEffect(() => {
if (countdown <= 0) return;
timerRef.current = setInterval(() => {
const id = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
clearInterval(id);
return 0;
}
return prev - 1;
});
}, 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, [countdown > 0]); // eslint-disable-line react-hooks/exhaustive-deps
return () => clearInterval(id);
}, [resendCount]); // 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 };
return { countdown, triggerCooldown, maxedOut: resendCount >= MAX_RESENDS };
}
function useExpiryTimer(codeExpiresAt: string | null) {
@ -83,31 +74,6 @@ function useExpiryTimer(codeExpiresAt: string | null) {
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,
@ -218,7 +184,7 @@ export function VerificationStep() {
</p>
)}
<ExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} />
<OtpExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} />
<div className="space-y-3">
<Button

View File

@ -12,19 +12,12 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Button } from "@/components/atoms";
import { OtpInput } from "@/components/molecules";
import { Clock } from "lucide-react";
import { OtpInput, OtpExpiryDisplay } from "@/components/molecules";
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);
@ -48,31 +41,6 @@ function useExpiryTimer() {
return { timeRemaining, isExpired, resetExpiry };
}
function ExpiryDisplay({
timeRemaining,
isExpired,
}: {
timeRemaining: number | null;
isExpired: boolean;
}) {
if (isExpired) {
return (
<p className="text-sm text-danger text-center" role="alert">
Code expired. Please request a new one.
</p>
);
}
if (timeRemaining !== null) {
return (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>Code expires in {formatTimeRemaining(timeRemaining)}</span>
</div>
);
}
return null;
}
function OtpActions({
loading,
resendDisabled,
@ -137,13 +105,9 @@ export function OtpStep() {
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);
// Clear input whenever a new error appears
useEffect(() => {
if (otpError && otpError !== prevErrorRef.current) {
setOtpValue("");
}
prevErrorRef.current = otpError;
if (otpError) setOtpValue("");
}, [otpError]);
const handleCodeChange = useCallback(
@ -157,13 +121,14 @@ export function OtpStep() {
const handleComplete = useCallback(
async (code: string) => {
if (isExpired) return;
clearOtpError(); // Ensure null→error transition so the effect always fires
try {
await verifyOtp(code);
} catch {
setOtpValue("");
// error is set by store; effect above clears the input
}
},
[verifyOtp, isExpired]
[verifyOtp, isExpired, clearOtpError]
);
const handleVerify = () => {
@ -205,7 +170,7 @@ export function OtpStep() {
</p>
)}
<ExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} />
<OtpExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} />
<Button
type="button"