Created
July 22, 2025 14:41
-
-
Save radiovisual/f72d78fdc0c2741ceee0b49e252d1074 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// utils/RobustPortal.tsx | |
import { createPortal } from 'react-dom'; | |
import React, { useContext, createContext, useState, useEffect, useCallback, useRef } from 'react'; | |
// Enhanced portal context with timing guarantees | |
interface ProcessWrapperPortalContextType { | |
registerPortal: (portalId: string, element: HTMLElement | null) => void; | |
isPortalReady: (portalId: string) => boolean; | |
getPortalElement: (portalId: string) => HTMLElement | null; | |
portalReadyStates: Map<string, boolean>; | |
} | |
export const ProcessWrapperPortalContext = createContext<ProcessWrapperPortalContextType>({ | |
registerPortal: () => {}, | |
isPortalReady: () => false, | |
getPortalElement: () => null, | |
portalReadyStates: new Map() | |
}); | |
// Robust Portal Provider with timing coordination | |
export const ProcessWrapperPortalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { | |
const [portals, setPortals] = useState<Map<string, HTMLElement | null>>(new Map()); | |
const [readyStates, setReadyStates] = useState<Map<string, boolean>>(new Map()); | |
const observerRef = useRef<MutationObserver | null>(null); | |
const registerPortal = useCallback((portalId: string, element: HTMLElement | null) => { | |
setPortals(prev => { | |
const newMap = new Map(prev); | |
newMap.set(portalId, element); | |
return newMap; | |
}); | |
setReadyStates(prev => { | |
const newMap = new Map(prev); | |
newMap.set(portalId, !!element); | |
return newMap; | |
}); | |
}, []); | |
const isPortalReady = useCallback((portalId: string) => { | |
return readyStates.get(portalId) || false; | |
}, [readyStates]); | |
const getPortalElement = useCallback((portalId: string) => { | |
return portals.get(portalId) || null; | |
}, [portals]); | |
// Set up MutationObserver to handle dynamic DOM changes | |
useEffect(() => { | |
observerRef.current = new MutationObserver((mutations) => { | |
let needsUpdate = false; | |
mutations.forEach((mutation) => { | |
if (mutation.type === 'childList') { | |
// Check if any registered portal elements were added/removed | |
portals.forEach((element, portalId) => { | |
if (element) { | |
const stillExists = document.contains(element); | |
const currentState = readyStates.get(portalId); | |
if (currentState !== stillExists) { | |
needsUpdate = true; | |
setReadyStates(prev => { | |
const newMap = new Map(prev); | |
newMap.set(portalId, stillExists); | |
return newMap; | |
}); | |
} | |
} | |
}); | |
} | |
}); | |
}); | |
observerRef.current.observe(document.body, { | |
childList: true, | |
subtree: true, | |
attributes: false | |
}); | |
return () => { | |
if (observerRef.current) { | |
observerRef.current.disconnect(); | |
} | |
}; | |
}, [portals, readyStates]); | |
const contextValue = { | |
registerPortal, | |
isPortalReady, | |
getPortalElement, | |
portalReadyStates: readyStates | |
}; | |
return ( | |
<ProcessWrapperPortalContext.Provider value={contextValue}> | |
{children} | |
</ProcessWrapperPortalContext.Provider> | |
); | |
}; | |
// Enhanced FooterPortal with timing guarantees and error handling | |
export const FooterPortal: React.FC<{ | |
children: React.ReactNode; | |
portalId?: string; | |
fallback?: React.ReactNode; | |
}> = ({ children, portalId = 'process-wrapper-footer', fallback = null }) => { | |
const { isPortalReady, getPortalElement } = useContext(ProcessWrapperPortalContext); | |
const [renderAttempts, setRenderAttempts] = useState(0); | |
const maxRetries = 10; | |
// Retry mechanism for portal availability | |
useEffect(() => { | |
if (!isPortalReady(portalId) && renderAttempts < maxRetries) { | |
const timer = setTimeout(() => { | |
setRenderAttempts(prev => prev + 1); | |
}, 50 + (renderAttempts * 50)); // Exponential backoff | |
return () => clearTimeout(timer); | |
} | |
}, [portalId, isPortalReady, renderAttempts, maxRetries]); | |
// Reset retry counter when portal becomes ready | |
useEffect(() => { | |
if (isPortalReady(portalId)) { | |
setRenderAttempts(0); | |
} | |
}, [portalId, isPortalReady]); | |
const portalElement = getPortalElement(portalId); | |
if (!isPortalReady(portalId) || !portalElement) { | |
// Show fallback or nothing while waiting for portal target | |
return renderAttempts >= maxRetries ? (fallback as React.ReactElement) : null; | |
} | |
try { | |
return createPortal(children, portalElement); | |
} catch (error) { | |
console.error('FooterPortal render failed:', error); | |
return fallback as React.ReactElement; | |
} | |
}; | |
// Hook for managing portal elements with timing coordination | |
export const usePortalElement = (portalId: string) => { | |
const { registerPortal, isPortalReady } = useContext(ProcessWrapperPortalContext); | |
const elementRef = useRef<HTMLDivElement>(null); | |
const [isReady, setIsReady] = useState(false); | |
useEffect(() => { | |
const element = elementRef.current; | |
if (element) { | |
// Register the portal immediately | |
registerPortal(portalId, element); | |
setIsReady(true); | |
} | |
return () => { | |
// Clean up on unmount | |
registerPortal(portalId, null); | |
setIsReady(false); | |
}; | |
}, [portalId, registerPortal]); | |
return { elementRef, isReady, isPortalReady: isPortalReady(portalId) }; | |
}; | |
// ContactDataContainer.tsx - Updated with robust portal | |
import React, { useState, useEffect } from 'react'; | |
import { ContactData } from './ContactData'; | |
import { StepProps } from './types/StepComponent'; | |
import { FooterPortal } from '../utils/RobustPortal'; | |
import { EMAIL_PATTERN } from '../../../utilities/constants'; | |
export function ContactDataContainer(props: ContactDataContainerProps & StepProps) { | |
const { | |
title, onClose, onNext, locale, isNative, formValues, | |
setEmailAddress, emailAddresses, translate | |
} = props; | |
const [emailErrorMessage, setEmailErrorMessage] = useState(''); | |
const [isSubmitting, setIsSubmitting] = useState(false); | |
const [shouldShowPortal, setShouldShowPortal] = useState(false); | |
const emailValidationErrorMessages = { | |
required: translate('payments.inbox.validation.required'), | |
pattern: translate('payments.inbox.validation.pattern'), | |
}; | |
const validateEmail = (email: string) => { | |
let validationError = ''; | |
if (!(email || '').trim()) { | |
validationError = emailValidationErrorMessages['required']; | |
} else if (!EMAIL_PATTERN.test(email)) { | |
validationError = emailValidationErrorMessages['pattern']; | |
} | |
return validationError; | |
}; | |
const handleSubmit = async () => { | |
const emailValidationError = validateEmail(formValues?.email); | |
setEmailErrorMessage(emailValidationError); | |
if (!emailValidationError) { | |
setIsSubmitting(true); | |
try { | |
// Your existing async validation logic | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
onNext(); | |
} catch (error) { | |
setEmailErrorMessage('Email validation failed'); | |
} finally { | |
setIsSubmitting(false); | |
} | |
} | |
}; | |
const isValid = !!formValues?.email && !validateEmail(formValues.email); | |
// Show portal after a brief delay to ensure parent is ready | |
useEffect(() => { | |
const timer = setTimeout(() => { | |
setShouldShowPortal(true); | |
}, 100); | |
return () => clearTimeout(timer); | |
}, []); | |
if (!props.isActive) return null; | |
return ( | |
<> | |
<ContactData | |
locale={locale} | |
firstName={formValues?.firstName} | |
lastName={formValues?.lastName} | |
emailAddress={formValues?.email} | |
setEmailAddress={setEmailAddress} | |
emailAddresses={emailAddresses} | |
emailErrorMessage={emailErrorMessage} | |
/> | |
{shouldShowPortal && ( | |
<FooterPortal | |
portalId="process-wrapper-footer" | |
fallback={ | |
<div style={{ padding: '16px', textAlign: 'center' }}> | |
<button onClick={handleSubmit} disabled={!isValid}> | |
{translate('payments.inbox.buttons.labels.next')} | |
</button> | |
</div> | |
} | |
> | |
<div className="footer-buttons-container"> | |
<button | |
id="contact-data-next-btn" | |
type="button" | |
disabled={!isValid || isSubmitting} | |
onClick={handleSubmit} | |
className={`btn ${isValid && !isSubmitting ? 'btn-primary' : 'btn-disabled'}`} | |
> | |
{isSubmitting ? ( | |
<> | |
<span className="loading-spinner" /> | |
{translate('common.validating')} | |
</> | |
) : ( | |
translate('payments.inbox.buttons.labels.next') | |
)} | |
</button> | |
</div> | |
</FooterPortal> | |
)} | |
</> | |
); | |
} | |
// EBillRegistration.tsx - Updated main component with robust portal management | |
import React, { useState, useMemo, useEffect } from 'react'; | |
import { ProcessWrapper } from '@ubs-hba/responsive'; | |
import { ProcessWrapperPortalProvider, usePortalElement } from './utils/RobustPortal'; | |
import { ContactDataContainer } from './Steps/ContactData/ContactDataContainer'; | |
import { EmailConfirmationContainer } from './Steps/EmailConfirmation/EmailConfirmationContainer'; | |
export function EBillRegistration({ | |
locale, | |
onClose, | |
isNative = true, | |
onNavigateToEbillSettings, | |
onNavigateToOverview, | |
}: EBillRegistrationProps) { | |
const translate = useMemo(() => createTranslate(locale), [locale]); | |
const [currentStep, setCurrentStep] = useState(StepTypeId.CONTACT_DATA); | |
const [completedSteps, setCompletedSteps] = useState<number[]>([]); | |
const [formValues, setFormValues] = useState<any>({}); | |
const [emailAddresses, setEmailAddresses] = useState<string[]>([]); | |
// Use the portal element hook for footer management | |
const { elementRef: footerElementRef, isReady: footerReady } = usePortalElement('process-wrapper-footer'); | |
// Navigation handlers | |
const goToNextStep = () => { | |
setCompletedSteps(prev => [...prev.filter(s => s !== currentStep), currentStep]); | |
if (currentStep === StepTypeId.CONTACT_DATA) { | |
setCurrentStep(StepTypeId.CONTACT_DATA_CONFIRMATION); | |
} else if (currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION) { | |
setCurrentStep(StepTypeId.INVOICE_ADDRESS); | |
} else if (currentStep === StepTypeId.CONFIRMATION_CODE) { | |
setCurrentStep(StepTypeId.INVOICE_ADDRESS); | |
} else if (currentStep === StepTypeId.INVOICE_ADDRESS) { | |
setCurrentStep(StepTypeId.SUCCESS_CONFIRMATION); | |
} | |
}; | |
const goToPreviousStep = () => { | |
if (currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION) { | |
setCurrentStep(StepTypeId.CONTACT_DATA); | |
} else if (currentStep === StepTypeId.INVOICE_ADDRESS) { | |
setCurrentStep(StepTypeId.CONTACT_DATA_CONFIRMATION); | |
} else if (currentStep === StepTypeId.CONFIRMATION_CODE) { | |
setCurrentStep(StepTypeId.CONTACT_DATA_CONFIRMATION); | |
} else if (currentStep === StepTypeId.SUCCESS_CONFIRMATION) { | |
setCurrentStep(StepTypeId.INVOICE_ADDRESS); | |
} | |
}; | |
const goToError = () => setCurrentStep(StepTypeId.ERROR_PAGE); | |
// Step titles | |
const step1Title = translate('payments.inbox.payment.ebillRegistration.step1.process.title'); | |
const step2Title = translate('payments.inbox.payment.ebillRegistration.step2.process.title'); | |
const step3Title = translate('payments.inbox.payment.ebillRegistration.step3.process.title'); | |
const step4Title = translate('payments.inbox.payment.ebillRegistration.step4.process.title'); | |
const navigationProps = { | |
steps: [step1Title, step2Title, step3Title, step4Title], | |
active: currentStep, | |
completed: completedSteps, | |
showIcon: true, | |
}; | |
const wrapperTitle = translate('payments.inbox.payment.ebillRegistration.digitalBanking'); | |
const wrapperSubtitle = translate('payments.inbox.payment.ebillRegistration'); | |
const commonStepProps = { | |
locale, | |
translate, | |
formValues, | |
onClose, | |
onNext: goToNextStep, | |
onPrevious: goToPreviousStep, | |
onDataChange: setFormValues | |
}; | |
if (currentStep === StepTypeId.ERROR_PAGE) { | |
return ( | |
<ErrorPageContainer | |
title={wrapperTitle} | |
translate={translate} | |
isNative={isNative} | |
locale={locale} | |
onClose={onClose} | |
/> | |
); | |
} | |
return ( | |
<ProcessWrapperPortalProvider> | |
<ProcessWrapper | |
isNativeApp={isNative} | |
backLabel="Back" | |
closeLabel="Close" | |
title={[wrapperTitle, wrapperSubtitle]} | |
navigationProps={navigationProps} | |
onCancel={() => console.log('cancel')} | |
> | |
<ProcessWrapper.Header | |
title={[wrapperTitle, wrapperSubtitle]} | |
navigationProps={navigationProps} | |
/> | |
<ProcessWrapper.Content> | |
{/* EmailConfirmation modal overlays ContactData when active */} | |
{(currentStep === StepTypeId.CONTACT_DATA || currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION) && ( | |
<EmailConfirmationContainer | |
{...commonStepProps} | |
show={currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION} | |
title={wrapperTitle} | |
isNative={isNative} | |
account={accountDataResponse?.data} | |
skipToConfirmationCode={() => setCurrentStep(StepTypeId.CONFIRMATION_CODE)} | |
isActive={currentStep === StepTypeId.CONTACT_DATA_CONFIRMATION} | |
/> | |
)} | |
{currentStep === StepTypeId.CONTACT_DATA && ( | |
<ContactDataContainer | |
{...commonStepProps} | |
title={wrapperTitle} | |
isNative={isNative} | |
emailAddresses={emailAddresses} | |
setEmailAddress={(value: string) => | |
setFormValues(prev => ({ ...prev, email: value })) | |
} | |
isActive={true} | |
/> | |
)} | |
</ProcessWrapper.Content> | |
{/* Footer with portal target */} | |
<ProcessWrapper.Footer> | |
<div | |
ref={footerElementRef} | |
className="process-wrapper-footer-content" | |
data-portal-ready={footerReady} | |
style={{ minHeight: '60px' }} // Prevent layout shift | |
> | |
{/* Debug info - remove in production */} | |
{process.env.NODE_ENV === 'development' && !footerReady && ( | |
<div style={{ color: '#666', fontSize: '12px' }}> | |
Portal target loading... (isNative: {isNative.toString()}) | |
</div> | |
)} | |
</div> | |
</ProcessWrapper.Footer> | |
</ProcessWrapper> | |
</ProcessWrapperPortalProvider> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment