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:
parent
05d3467efc
commit
18b4c515a4
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export { OtpInput } from "./OtpInput";
|
||||
export { OtpExpiryDisplay, formatTimeRemaining } from "./OtpExpiryDisplay";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user