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 {
|
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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 { OtpInput } from "./OtpInput";
|
||||||
|
export { OtpExpiryDisplay, formatTimeRemaining } from "./OtpExpiryDisplay";
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user