import { usePaymentDispatch, usePaymentSelect, useValidationDispatch, ValidationSelectors } from '@greenpay/redux' import { visibilityToggle } from '@greenpay/utils' import { FormControl, InputLabel, IconButton, InputAdornment, OutlinedInput, FormHelperText, Grid2, } from '@mui/material' import { Visibility, VisibilityOff } from '@mui/icons-material' import { useSelect } from '@wordpress/data' import { useId, useState, useEffect } from '@wordpress/element' import styles from './Debit.module.scss' import React, { useRef, useLayoutEffect, useState as useReactState } from 'react' import ReactDOM from 'react-dom' import { CacheProvider } from '@emotion/react' import createCache from '@emotion/cache' // Wrap the input controls in a Shadow DOM to isolate styles from global WordPress styles const DebitComponent: React.FC = () => { const { validateRouting, validateAccount, setRoutingTouched, setAccountTouched } = useValidationDispatch() const { setDebit, setMethod } = usePaymentDispatch() const { getDebit } = usePaymentSelect() const getRoutingError = useSelect( (select) => (select('green-pay/validation') as ValidationSelectors).getRoutingError(), [], ) const getAccountError = useSelect( (select) => (select('green-pay/validation') as ValidationSelectors).getAccountError(), [], ) const getRoutingTouched = useSelect( (select) => (select('green-pay/validation') as ValidationSelectors).getRoutingTouched(), [], ) const getAccountTouched = useSelect( (select) => (select('green-pay/validation') as ValidationSelectors).getAccountTouched(), [], ) const [showRouting, setShowRouting] = visibilityToggle(false) const [showAccount, setShowAccount] = visibilityToggle(false) const [values, setValues] = useState({ routing: getDebit()?.routingNumber ?? '', account: getDebit()?.accountNumber ?? '', }) // Debounced Redux update to prevent triggering WooCommerce events on every keystroke const debounceTimeoutRef = useRef | null>(null) const methodTimeoutRef = useRef | null>(null) // Sync local state with Redux state changes useEffect(() => { const debitData = getDebit() setValues({ routing: debitData?.routingNumber ?? '', account: debitData?.accountNumber ?? '', }) }, [getDebit]) // Debounced function to update Redux state const debouncedSetDebit = (routing: string, account: string) => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current) } debounceTimeoutRef.current = setTimeout(() => { setDebit({ routingNumber: routing, accountNumber: account }) }, 300) // 300ms delay } // Debounced method setting to prevent infinite loops from updated_checkout events const debouncedSetMethod = (method: string) => { if (methodTimeoutRef.current) { clearTimeout(methodTimeoutRef.current) } methodTimeoutRef.current = setTimeout(() => { setMethod(method) }, 100) // 100ms delay to prevent rapid updates } const idRouting = useId() const idAccount = useId() // Allow only numeric characters const handleOnlyNumerical = (e: React.ChangeEvent) => { const { name, value } = e.target if (/^\d*$/.test(value)) { setValues((prev) => { const newValues = { ...prev, [name]: value } // Debounced Redux update to prevent triggering WooCommerce events on every keystroke debouncedSetDebit(newValues.routing, newValues.account) return newValues }) } } const handleBlur = (name: 'routing' | 'account') => { let isValid = true if (name === 'routing') { isValid = validateRouting(values.routing) setRoutingTouched(true) } else { isValid = validateAccount(values.account) setAccountTouched(true) } // Always update the Redux state, even if validation fails // This ensures empty values are properly stored and cleared setDebit({ routingNumber: values.routing, accountNumber: values.account }) // Only set method if we're in the main checkout form, not in a dialog // Check if we're inside a dialog by looking for a parent dialog element const isInDialog = shadowHostRef.current?.closest('[role="dialog"]') !== null if (isValid && !isInDialog) { debouncedSetMethod('debit') } } // Shadow DOM logic const shadowHostRef = useRef(null) const shadowRootRef = useRef(null) const [ready, setReady] = useReactState(false) const [emotionCache, setEmotionCache] = useReactState(null) useLayoutEffect(() => { if (shadowHostRef.current && !shadowRootRef.current) { shadowRootRef.current = shadowHostRef.current.attachShadow({ mode: 'open' }) } }, []) useLayoutEffect(() => { if (shadowRootRef.current) { let mountPoint = shadowRootRef.current.getElementById('debit-shadow-root') if (!mountPoint) { mountPoint = document.createElement('div') mountPoint.id = 'debit-shadow-root' shadowRootRef.current.appendChild(mountPoint) } // Create an emotion cache targeting the shadow root if (!emotionCache) { setEmotionCache(createCache({ key: 'mui', container: shadowRootRef.current })) } setReady(true) } }, [emotionCache]) // Cleanup debounce timeout on unmount useEffect(() => { return () => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current) } if (methodTimeoutRef.current) { clearTimeout(methodTimeoutRef.current) } } }, []) // Render the form into the ShadowRoot using a portal const form = ( Routing Number handleBlur('routing')} inputProps={{ maxLength: 9 }} autoComplete="off" endAdornment={ {showRouting ? : } } label="Routing Number" /> {getRoutingTouched ? getRoutingError : ''} Account Number handleBlur('account')} inputProps={{ maxLength: 17 }} autoComplete="off" endAdornment={ {showAccount ? : } } label="Account Number" /> {getAccountTouched ? getAccountError : ''} ) return (
{/* Hidden marker to prevent :empty from matching */} {ready && shadowRootRef.current && emotionCache ? ReactDOM.createPortal( {form}, shadowRootRef.current.getElementById('debit-shadow-root')!, ) : null}
) } export default DebitComponent