// ── custom element ───────────────────────── // // Form-associated custom element that renders the React composite in **light // DOM** (NOT Shadow DOM). Style scoping relies on the `.wcp-pf__*` BEM class // prefix rather than structural isolation. // // Why not Shadow DOM: Clover's `element.mount('#selector')` uses // `document.querySelector` to find the mount point, and that does NOT pierce // shadowRoot boundaries. Iframes never mount if the mount-points live inside // a shadowRoot. A future v0.2 can re-introduce Shadow DOM via slot projection // (mount-points in light DOM, visual layout in shadowRoot) — the architectural // cost wasn't worth blocking the v0.1 ship. // // Lifecycle: // - constructor: attach element internals (form-associated) // - connectedCallback: parse attributes, build PaymentFieldsConfig, // construct PaymentFieldsMachine, mount React composite, subscribe for events // - disconnectedCallback: unsubscribe, unmount React, destroy machine // // Imperative methods (`tokenize`, `runThreeDs`, `resetToIdle`) are exposed on // the element instance for hosts that need direct control (e.g., the WC // Classic adapter wires `tokenize()` to the `checkout_place_order` jQuery // event, since WC owns the submit lifecycle). // // Custom events bubble + cross the shadowRoot boundary (`composed: true`): // wcp-gpay-start — Clover paymentMethodStart fired // wcp-gpay-ready — Clover paymentMethod fired (token captured, awaiting Place Order) // wcp-gpay-cancel — user cancelled the GPay sheet // wcp-3ds-start — entering threeds_method or threeds_challenge // wcp-3ds-done — terminal success after 3DS // wcp-tokenized — token is ready (manual card OR GPay), form value is set // wcp-error — anything went wrong import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { PaymentFieldsMachine, type HeadersProvider, type PaymentFieldsConfig, type PaymentFieldsError, type PaymentFieldsSnapshot, type PaymentFieldsState, type ThreeDsInterim, type ThreeDsResult, type TokenizationResult, } from '../core/index'; import { PaymentFields } from '../react/PaymentFields'; /** * Optional setter for the host's headers provider — must be set BEFORE the * element is connected (e.g., via `el.headersProvider = ...; document.body.appendChild(el)`). */ export interface WcpPaymentFieldsElement extends HTMLElement { headersProvider?: HeadersProvider; tokenize: () => Promise; runThreeDs: ( interim: ThreeDsInterim, options?: { readonly extraBody?: Record }, ) => Promise; resetToIdle: () => void; readonly machine: PaymentFieldsMachine | null; } const STATE_EVENT_MAP: Partial> = { gpay_opening: 'wcp-gpay-start', gpay_ready: 'wcp-gpay-ready', gpay_cancelled: 'wcp-gpay-cancel', threeds_method: 'wcp-3ds-start', threeds_challenge: 'wcp-3ds-start', done: 'wcp-3ds-done', }; export class WcpPaymentFields extends HTMLElement implements WcpPaymentFieldsElement { /** Tells the form machinery that this element participates in form submission. */ static readonly formAssociated = true; private internals: ElementInternals; /** Light-DOM mount container for the React tree. NOT a shadowRoot. */ private container: HTMLDivElement | null = null; private root: Root | null = null; private _machine: PaymentFieldsMachine | null = null; private unsubscribe: (() => void) | null = null; private lastTokenizedFor: string | null = null; private lastErrorCode: string | null = null; private lastState: PaymentFieldsState | null = null; /** Caller-provided headers function (for 3DS finalize POST). Property — not an attribute. */ headersProvider?: HeadersProvider; constructor() { super(); this.internals = this.attachInternals(); } // ── lifecycle ── connectedCallback(): void { console.log('[wcp-pf] connectedCallback fired'); if (this.root) { console.log('[wcp-pf] already has root, skipping'); return; } const config = this.parseConfig(); if (!config) { console.warn('[wcp-pf] missing required attributes; element will not render', { pakms: this.getAttribute('pakms-key'), merchant: this.getAttribute('merchant-id'), cloverSdkUrl: this.getAttribute('clover-sdk-url'), }); return; } console.log('[wcp-pf] config parsed', config); this._machine = new PaymentFieldsMachine(config); this.subscribeMachineToEvents(); const lockIconSrc = this.getAttribute('lock-icon-src') ?? ''; const trustLogosSrc = this.getAttribute('trust-logos-src') ?? ''; const trustText = this.getAttribute('trust-text') ?? undefined; // Light-DOM container — Clover's `element.mount('#id')` uses // `document.querySelector` which cannot pierce shadowRoots, so the mount // points must be reachable from the document root. this.container = document.createElement('div'); this.appendChild(this.container); console.log('[wcp-pf] rendering React composite into light-DOM container'); this.root = createRoot(this.container); this.root.render( createElement(PaymentFields, { machine: this._machine, lockIconSrc, trustLogosSrc, trustText, }), ); console.log('[wcp-pf] React render initiated'); // Notify the host that this element instance is connected so it can // (re-)assign per-instance state like `headersProvider`. WooCommerce's // `update_order_review` AJAX disconnects + reconnects a fresh element on // every shipping/billing change, and the host needs a hook to refresh its // bindings on each new instance. Bubbles + composes so the host can // listen on `document.body` for any future element instance. this.dispatchTyped('wcp-mounted', { pakmsKey: this.getAttribute('pakms-key') }); } disconnectedCallback(): void { console.log('[wcp-pf] disconnectedCallback fired'); this.unsubscribe?.(); this.unsubscribe = null; this.root?.unmount(); this.root = null; this.container?.remove(); this.container = null; this._machine?.destroy(); this._machine = null; this.internals.setFormValue(null); } // ── public imperative API ── get machine(): PaymentFieldsMachine | null { return this._machine; } async tokenize(): Promise { if (!this._machine) { throw new Error(' not connected.'); } return this._machine.tokenize(); } async runThreeDs( interim: ThreeDsInterim, options?: { readonly extraBody?: Record }, ): Promise { if (!this._machine) { throw new Error(' not connected.'); } return this._machine.runThreeDs(interim, options); } resetToIdle(): void { this._machine?.resetToIdle(); } // ── form-association callbacks ── formResetCallback(): void { this.resetToIdle(); } // ── internal ── private subscribeMachineToEvents(): void { if (!this._machine) { return; } this.unsubscribe = this._machine.subscribe((snapshot) => this.onSnapshot(snapshot)); } private onSnapshot(snapshot: PaymentFieldsSnapshot): void { // State transition events (each only fires when the state actually changes). if (snapshot.state !== this.lastState) { const eventName = STATE_EVENT_MAP[snapshot.state]; if (eventName) { this.dispatchTyped(eventName, snapshot); } this.lastState = snapshot.state; } // Token ready (either path). Form value is set so the host `
` submits it. if (snapshot.result && snapshot.result.token !== this.lastTokenizedFor) { this.lastTokenizedFor = snapshot.result.token; this.internals.setFormValue(snapshot.result.token); this.dispatchTyped('wcp-tokenized', snapshot.result); } // Error transitions if (snapshot.error && snapshot.error.code !== this.lastErrorCode) { this.lastErrorCode = snapshot.error.code; this.dispatchTyped('wcp-error', snapshot.error); } else if (!snapshot.error) { this.lastErrorCode = null; } } private dispatchTyped(name: string, detail: unknown): void { this.dispatchEvent( new CustomEvent(name, { detail, bubbles: true, composed: true, // crosses shadowRoot boundary }), ); } private parseConfig(): PaymentFieldsConfig | null { const pakmsKey = this.getAttribute('pakms-key'); const merchantId = this.getAttribute('merchant-id'); const cloverSdkUrl = this.getAttribute('clover-sdk-url'); if (!pakmsKey || !merchantId || !cloverSdkUrl) { return null; } const locale = this.getAttribute('locale') ?? 'en'; const currency = this.getAttribute('currency') ?? 'CAD'; const country = this.getAttribute('country') ?? undefined; const cartTotal = parseIntAttr(this.getAttribute('cart-total'), 0); const clover3DSSdkUrl = this.getAttribute('clover-3ds-sdk-url') ?? undefined; const features = parseJsonAttr( this.getAttribute('features'), ); const endpointsRaw = parseJsonAttr<{ threeDsFinalize?: string }>( this.getAttribute('endpoints'), ); const theme = parseJsonAttr(this.getAttribute('theme')); // Defer the headersProvider lookup until the SDK actually calls it. The // host (e.g., wc-classic.ts) assigns `el.headersProvider` from its // DOMContentLoaded bootstrap, which fires AFTER the custom element has // already connected — capturing `this.headersProvider` here would freeze // it at undefined and the 3DS finalize POST would go out without // `X-WP-Nonce`. A closure that re-reads `this.headersProvider` at call // time sees whatever the host has assigned by then. const lazyHeaders: HeadersProvider = async () => { const provider = this.headersProvider; if (!provider) { return {}; } return provider(); }; return { pakmsKey, merchantId, locale, cartTotal, currency, country, cloverSdkUrl, clover3DSSdkUrl, features, endpoints: { threeDsFinalize: endpointsRaw?.threeDsFinalize, headers: lazyHeaders, }, theme, }; } } function parseJsonAttr(value: string | null): T | undefined { if (!value) { return undefined; } try { return JSON.parse(value) as T; } catch (error) { console.warn('[wcp-payment-fields] failed to parse JSON attribute', value, error); return undefined; } } function parseIntAttr(value: string | null, fallback: number): number { if (!value) { return fallback; } const parsed = parseInt(value, 10); return Number.isFinite(parsed) ? parsed : fallback; } // Re-export the error type for consumers reading event.detail export type { PaymentFieldsError };