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 { try {
// Search for Contact with matching email and get the associated Account + Contact details // Search for Contact with matching email and get the associated Account + Contact details
const result = (await this.connection.query( 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" } { label: "checkout:findAccountByEmail" }
)) as SalesforceResponse<{ )) 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; FirstName?: string | null;
LastName?: string | null; LastName?: string | null;
MailingStreet?: string | null; MailingStreet?: string | null;
@ -174,12 +175,14 @@ export class SalesforceAccountService {
if (result.totalSize > 0 && result.records[0]?.Account) { if (result.totalSize > 0 && result.records[0]?.Account) {
const record = result.records[0]; const record = result.records[0];
const portalSource = (record.Account[this.portalSourceField] as string | null) ?? undefined;
const hasAddress = record.MailingCity || record.MailingState || record.MailingPostalCode; const hasAddress = record.MailingCity || record.MailingState || record.MailingPostalCode;
return { return {
id: record.Account.Id, id: record.Account.Id,
accountNumber: record.Account.SF_Account_No__c, accountNumber: record.Account.SF_Account_No__c,
...(record.FirstName && { firstName: record.FirstName }), ...(record.FirstName && { firstName: record.FirstName }),
...(record.LastName && { lastName: record.LastName }), ...(record.LastName && { lastName: record.LastName }),
...(portalSource && { portalSource }),
...(hasAddress && { ...(hasAddress && {
address: { address: {
address1: record.MailingStreet || "", address1: record.MailingStreet || "",
@ -560,6 +563,7 @@ export interface FindByEmailResult {
accountNumber: string; accountNumber: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
portalSource?: string;
address?: { address?: {
address1: string; address1: string;
city: string; city: string;

View File

@ -11,6 +11,8 @@ import {
} from "@customer-portal/domain/get-started"; } from "@customer-portal/domain/get-started";
import { STREET_ADDRESS_PATTERN } from "@customer-portal/domain/address"; 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 { 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";
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
@ -215,7 +217,12 @@ 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; // 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 { return {
status: ACCOUNT_STATUS.SF_UNMAPPED, status: ACCOUNT_STATUS.SF_UNMAPPED,
sfAccountId: sfAccount.id, 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 { OtpInput } from "./OtpInput";
export { OtpExpiryDisplay, formatTimeRemaining } from "./OtpExpiryDisplay";

View File

@ -10,7 +10,7 @@ export type { DataTableProps, Column } from "./DataTable/DataTable";
// Form components // Form components
export { FormField } from "./FormField/FormField"; export { FormField } from "./FormField/FormField";
export type { FormFieldProps } 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 { SearchFilterBar } from "./SearchFilterBar/SearchFilterBar";
export type { SearchFilterBarProps, FilterOption } from "./SearchFilterBar/SearchFilterBar"; export type { SearchFilterBarProps, FilterOption } from "./SearchFilterBar/SearchFilterBar";

View File

@ -9,7 +9,7 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { Button, ErrorMessage } from "@/components/atoms"; import { Button, ErrorMessage } from "@/components/atoms";
import { OtpInput } from "@/components/molecules"; import { OtpInput, formatTimeRemaining } from "@/components/molecules";
import { ArrowLeft, Mail, Clock } from "lucide-react"; import { ArrowLeft, Mail, Clock } from "lucide-react";
interface LoginOtpStepProps { interface LoginOtpStepProps {
@ -23,15 +23,6 @@ interface LoginOtpStepProps {
error?: string | null; 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({ export function LoginOtpStep({
sessionToken: _sessionToken, sessionToken: _sessionToken,
maskedEmail, maskedEmail,

View File

@ -99,8 +99,6 @@ 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

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

View File

@ -12,19 +12,12 @@
import { useState, useCallback, useEffect, useRef } 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, OtpExpiryDisplay } 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 MAX_RESENDS = 3;
const CODE_TTL_MS = 10 * 60 * 1000; 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() { function useExpiryTimer() {
const [timeRemaining, setTimeRemaining] = useState<number | null>(null); const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
const expiryRef = useRef<number>(Date.now() + CODE_TTL_MS); const expiryRef = useRef<number>(Date.now() + CODE_TTL_MS);
@ -48,31 +41,6 @@ function useExpiryTimer() {
return { timeRemaining, isExpired, resetExpiry }; 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,
@ -137,13 +105,9 @@ export function OtpStep() {
const [resendCount, setResendCount] = useState(0); const [resendCount, setResendCount] = useState(0);
const { timeRemaining, isExpired, resetExpiry } = useExpiryTimer(); const { timeRemaining, isExpired, resetExpiry } = useExpiryTimer();
// Track error changes to clear OTP input on each new error // Clear input whenever a new error appears
const prevErrorRef = useRef(otpError);
useEffect(() => { useEffect(() => {
if (otpError && otpError !== prevErrorRef.current) { if (otpError) setOtpValue("");
setOtpValue("");
}
prevErrorRef.current = otpError;
}, [otpError]); }, [otpError]);
const handleCodeChange = useCallback( const handleCodeChange = useCallback(
@ -157,13 +121,14 @@ export function OtpStep() {
const handleComplete = useCallback( const handleComplete = useCallback(
async (code: string) => { async (code: string) => {
if (isExpired) return; if (isExpired) return;
clearOtpError(); // Ensure null→error transition so the effect always fires
try { try {
await verifyOtp(code); await verifyOtp(code);
} catch { } catch {
setOtpValue(""); // error is set by store; effect above clears the input
} }
}, },
[verifyOtp, isExpired] [verifyOtp, isExpired, clearOtpError]
); );
const handleVerify = () => { const handleVerify = () => {
@ -205,7 +170,7 @@ export function OtpStep() {
</p> </p>
)} )}
<ExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} /> <OtpExpiryDisplay timeRemaining={timeRemaining} isExpired={isExpired} />
<Button <Button
type="button" type="button"