diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts index 598943c7..954972aa 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts @@ -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 & { 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; diff --git a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts index ff01135d..f02f9a07 100644 --- a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts @@ -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, diff --git a/apps/portal/src/components/molecules/OtpInput/OtpExpiryDisplay.tsx b/apps/portal/src/components/molecules/OtpInput/OtpExpiryDisplay.tsx new file mode 100644 index 00000000..70da32d7 --- /dev/null +++ b/apps/portal/src/components/molecules/OtpInput/OtpExpiryDisplay.tsx @@ -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 ( +

+ Code expired. Please request a new one. +

+ ); + } + if (timeRemaining !== null) { + return ( +
+ + Code expires in {formatTimeRemaining(timeRemaining)} +
+ ); + } + return null; +} diff --git a/apps/portal/src/components/molecules/OtpInput/index.ts b/apps/portal/src/components/molecules/OtpInput/index.ts index 053decd4..992288e9 100644 --- a/apps/portal/src/components/molecules/OtpInput/index.ts +++ b/apps/portal/src/components/molecules/OtpInput/index.ts @@ -1 +1,2 @@ export { OtpInput } from "./OtpInput"; +export { OtpExpiryDisplay, formatTimeRemaining } from "./OtpExpiryDisplay"; diff --git a/apps/portal/src/components/molecules/index.ts b/apps/portal/src/components/molecules/index.ts index 86b11e91..5959afac 100644 --- a/apps/portal/src/components/molecules/index.ts +++ b/apps/portal/src/components/molecules/index.ts @@ -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"; diff --git a/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx b/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx index c96a2071..373fe0b5 100644 --- a/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx +++ b/apps/portal/src/features/auth/components/LoginOtpStep/LoginOtpStep.tsx @@ -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, diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx index bc626e6d..c46f2576 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx @@ -99,8 +99,6 @@ export function CompleteAccountStep() { {isSfUnmappedIncomplete && ( diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx index 5dbab91e..7ae19573 100644 --- a/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx @@ -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 | 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 ( -

- Code expired. Please request a new one. -

- ); - } - if (timeRemaining !== null) { - return ( -
- - Code expires in {formatTimeRemaining(timeRemaining)} -
- ); - } - return null; -} - function ResendButton({ disabled, maxedOut, @@ -218,7 +184,7 @@ export function VerificationStep() {

)} - +