// ── Clover element lifecycle ──────────────────────────────────── // // Owns `elements.create()` per FieldKey and the subsequent `.mount()` calls. // Decoupled from React reconciliation so iframes persist across renders. // // Clover's SDK does not expose unmount/destroy on its elements. Cleanup is // best-effort: we drop our refs and let GC collect the iframes when their // host divs are removed from the DOM. import { type CloverElement, type CloverElementType, type CloverElementStyles, type CloverInstance, type CloverPaymentRequestData, type FieldKey, FIELD_KEY_TO_CLOVER_TYPE, type MountTargets, } from './types'; export interface ElementRegistryOptions { readonly clover: CloverInstance; readonly styles: CloverElementStyles; /** Required when creating the `paymentRequestButton` field. */ readonly paymentRequestData?: CloverPaymentRequestData; } export class ElementRegistry { private readonly elements = new Map(); private readonly options: ElementRegistryOptions; private destroyed = false; constructor(options: ElementRegistryOptions) { this.options = options; } /** * Create a Clover element for each key. Skips keys already created. * GPay button creation can throw on devices without PaymentRequest support — * caught + logged + skipped, never breaks the rest of the form. */ create(keys: readonly FieldKey[]): this { this.assertAlive(); const cloverElements = this.options.clover.elements(); for (const key of keys) { if (this.elements.has(key)) { continue; } const type: CloverElementType = FIELD_KEY_TO_CLOVER_TYPE[key]; const data: CloverElementStyles | CloverPaymentRequestData = key === 'paymentRequestButton' && this.options.paymentRequestData ? this.options.paymentRequestData : this.options.styles; try { const el = cloverElements.create(type, data); this.elements.set(key, el); } catch (error) { console.warn(`[wcp-payment-fields] failed to create element "${key}"`, error); } } return this; } /** * Mount each created element at its target CSS selector. Skips fields whose * target is missing or whose host div doesn't exist in the DOM yet. */ mount(targets: MountTargets): this { this.assertAlive(); this.mountIfTargeted('cardNumber', targets.cardNumber); this.mountIfTargeted('cardDate', targets.cardDate); this.mountIfTargeted('cardCvv', targets.cardCvv); this.mountIfTargeted('cardPostalCode', targets.cardPostalCode); this.mountIfTargeted('cardName', targets.cardName); this.mountIfTargeted('cardEmail', targets.cardEmail); this.mountIfTargeted('paymentRequestButton', targets.paymentRequestButton); return this; } get(key: FieldKey): CloverElement | undefined { return this.elements.get(key); } entries(): IterableIterator<[FieldKey, CloverElement]> { return this.elements.entries(); } destroy(): void { this.destroyed = true; this.elements.clear(); } private mountIfTargeted(key: FieldKey, selector: string | undefined): void { if (!selector) { return; } const el = this.elements.get(key); if (!el) { return; } const host = document.querySelector(selector); if (!host) { return; } try { el.mount(selector); } catch (error) { console.warn(`[wcp-payment-fields] failed to mount "${key}" at "${selector}"`, error); return; } // Clover's iframe document occasionally renders content a sub-pixel taller // than its viewport (Firefox shows a fading overlay scrollbar; Chrome a // permanent thin one). The CSS-only fixes don't fully suppress it because // (a) Clover's own `updateFrameStyles` postMessage sets inline `height` // on the iframe at runtime, racing our stylesheet, and (b) overflow on // an iframe element is honored inconsistently across browsers. The // deprecated `scrolling="no"` HTML attribute is still honored by every // browser we ship to and is the only reliable kill switch for cross- // origin iframe scrollbars. Apply it after Clover injects the iframe. suppressIframeScrollbars(host); } private assertAlive(): void { if (this.destroyed) { throw new Error('ElementRegistry has been destroyed.'); } } } /** * Apply `scrolling="no"` to every iframe Clover injects into the host element. * Runs once synchronously (Clover's mount is sync) and then via MutationObserver * in case Clover swaps the iframe later (e.g., re-creating it on `update()`). */ function suppressIframeScrollbars(host: Element): void { const apply = (frame: HTMLIFrameElement): void => { frame.setAttribute('scrolling', 'no'); // Belt-and-suspenders for browsers that honor CSS overflow on iframes. frame.style.overflow = 'hidden'; }; for (const frame of host.querySelectorAll('iframe')) { apply(frame as HTMLIFrameElement); } // Observe in case Clover replaces the iframe (e.g., on PaymentRequest // re-init when cart total changes). The observer is cheap and the host // node lives as long as the SDK does. const observer = new MutationObserver((mutations) => { for (const m of mutations) { for (const node of m.addedNodes) { if (node instanceof HTMLIFrameElement) { apply(node); } } } }); observer.observe(host, { childList: true, subtree: true }); }