import { type ApplePayButtonController, type ApplePayButtonListenerOptions, attachApplePayButtonListeners, getApplePayButtonInitialHeight, getApplePayButtonSrc, } from "./apple-pay"; import { createPaymentMethodFormSkeleton, createWalletButtonSkeleton, type PaymentMethodFormSkeletonOptions, resolveWalletButtonSkeletonBorderRadius, type WalletButtonSkeleton, type WalletButtonSkeletonOptions, } from "./form-skeleton"; import { attachGooglePayButtonListeners, type GooglePayButtonController, type GooglePayButtonListenerOptions, getGooglePayButtonInitialHeight, getGooglePayButtonSrc, } from "./google-pay"; import { attachPaymentMethodFormListeners, type BillingAddressRequirement, type CreditCardAdditionalFields, getBankAccountFormInitialHeight, getBankAccountFormSrc, getCreditCardFormInitialHeight, getCreditCardFormSrc, type PaymentMethodFormController, type PaymentMethodFormListenerOptions, } from "./payment-method-form"; import { attachPlaidBankUi } from "./plaid-bank-ui"; type Container = HTMLElement | string; function resolveContainer(container: Container): HTMLElement { if (typeof container === "string") { const element = document.querySelector(container); if (!(element instanceof HTMLElement)) { throw new Error( `[amos-js] Container "${container}" did not match any HTMLElement.`, ); } return element; } return container; } const FORM_IFRAME_STYLE: Partial = { width: "calc(100% + 8px)", transition: "opacity 150ms ease-in, height 200ms ease-in-out", margin: "0 -4px", opacity: "0", border: "0", }; const WALLET_IFRAME_STYLE: Partial = { width: "100%", transition: "height 200ms ease-in-out", margin: "0", opacity: "0", border: "0", }; const WALLET_SKELETON_IFRAME_STYLE: Partial = { position: "absolute", top: "0", left: "0", width: "100%", height: "100%", margin: "0", opacity: "0", // Do not interpolate opacity while the skeleton is showing. transition: "none", pointerEvents: "none", }; const SKELETON_IFRAME_STYLE: Partial = { position: "absolute", top: "0", left: "-4px", width: "calc(100% + 8px)", height: "100%", margin: "0", // Do not interpolate opacity while the skeleton is showing. transition: "none", pointerEvents: "none", }; function createIframe({ src, title, name, height, allow, className, style = FORM_IFRAME_STYLE, }: { src: string; title: string; name: string; height: string; allow?: string; className?: string; style?: Partial; }): HTMLIFrameElement { const iframe = document.createElement("iframe"); iframe.src = src; iframe.title = title; iframe.name = name; iframe.setAttribute("role", "presentation"); iframe.scrolling = "no"; if (allow) { iframe.allow = allow; } if (className != null) { iframe.className = className; } Object.assign(iframe.style, style, { height }); return iframe; } function mountPaymentMethodFormWithSkeleton({ host, iframe, listenerOptions, skeletonOptions, }: { host: HTMLElement; iframe: HTMLIFrameElement; listenerOptions: PaymentMethodFormListenerOptions; skeletonOptions: PaymentMethodFormSkeletonOptions; }): AmosPaymentMethodFormMountController { const wrapper = document.createElement("div"); wrapper.style.position = "relative"; wrapper.style.width = "100%"; wrapper.setAttribute("aria-busy", "true"); const skeleton = createPaymentMethodFormSkeleton(skeletonOptions); Object.assign(iframe.style, SKELETON_IFRAME_STYLE); let revealed = false; let lastHeight: string | undefined; let appearance = skeletonOptions.appearance; let heightTransitionTimer: ReturnType | undefined; let fallbackRevealTimer: ReturnType | undefined; let currentOptions = listenerOptions; function skeletonHeightPx(): number { return wrapper.getBoundingClientRect().height; } function reportedHeightPx(): number { return lastHeight ? Number.parseFloat(lastHeight) : Number.NaN; } function reveal(): void { if (revealed) { return; } revealed = true; if (fallbackRevealTimer !== undefined) { clearTimeout(fallbackRevealTimer); fallbackRevealTimer = undefined; } const skeletonPx = skeletonHeightPx(); const reportedPx = reportedHeightPx(); // lastHeight can still be the pre-appearance measure and shorter than // the host skeleton. Never shrink on first paint; a later UPDATE_HEIGHT // applies the post-appearance size. const revealPx = Number.isFinite(reportedPx) ? Math.max(reportedPx, skeletonPx) : skeletonPx; iframe.style.transition = "none"; iframe.style.position = ""; iframe.style.top = ""; iframe.style.left = ""; iframe.style.margin = FORM_IFRAME_STYLE.margin ?? ""; iframe.style.height = `${revealPx}px`; iframe.style.opacity = "1"; iframe.style.pointerEvents = ""; skeleton.element.remove(); wrapper.removeAttribute("aria-busy"); // Height easing is for later layout changes (ZIP show/hide, errors), // not the first paint — that would animate the last row into place. heightTransitionTimer = setTimeout(() => { iframe.style.transition = "height 200ms ease-in-out"; }, 400); } function startFallbackReveal(): void { if (revealed || fallbackRevealTimer !== undefined) { return; } fallbackRevealTimer = setTimeout(() => { reveal(); }, 1500); } const controller = attachPaymentMethodFormListeners(iframe, { ...listenerOptions, onHeightChange: (height) => { lastHeight = height; if (revealed) { iframe.style.height = height; } currentOptions.onHeightChange?.(height); }, onAppearanceReady: () => { reveal(); currentOptions.onAppearanceReady?.(); }, onIframeReady: () => { startFallbackReveal(); currentOptions.onIframeReady?.(); }, }); wrapper.append(skeleton.element, iframe); host.appendChild(wrapper); return { iframe, update(patch) { currentOptions = { ...currentOptions, ...patch }; // Keep the wrapped reveal listeners. Forwarding these would replace // them and leave the iframe at opacity 0. const rest = { ...patch }; delete rest.onAppearanceReady; delete rest.onHeightChange; delete rest.onIframeReady; controller.update(rest); if (!revealed && "appearance" in patch) { appearance = patch.appearance; skeleton.update({ ...skeletonOptions, appearance }); } }, focus(field) { controller.focus(field); }, destroy() { if (heightTransitionTimer !== undefined) { clearTimeout(heightTransitionTimer); } if (fallbackRevealTimer !== undefined) { clearTimeout(fallbackRevealTimer); } controller.destroy(); wrapper.remove(); }, }; } type WalletListenerOptions = { height?: string; onHeightChange?: (height: string) => void; onAppearanceReady?: () => void; onIframeReady?: () => void; buttonProps?: { buttonRadius?: number; style?: Record; }; }; type WalletListenerController = { update: (patch: Partial) => void; destroy: () => void; }; function mountWalletButtonWithSkeleton({ host, iframe, listenerOptions, iframeStyle, attachListeners, }: { host: HTMLElement; iframe: HTMLIFrameElement; listenerOptions: TOptions; iframeStyle?: Partial; attachListeners: ( iframe: HTMLIFrameElement, options: TOptions, ) => WalletListenerController; }): { iframe: HTMLIFrameElement; update: (patch: Partial) => void; destroy: () => void; } { const initialHeight = listenerOptions.height ?? "48px"; let skeletonOptions: WalletButtonSkeletonOptions = { height: initialHeight, borderRadius: resolveWalletButtonSkeletonBorderRadius({ iframeStyle, buttonProps: listenerOptions.buttonProps, }), }; const skeleton: WalletButtonSkeleton = createWalletButtonSkeleton(skeletonOptions); const wrapper = document.createElement("div"); wrapper.style.position = "relative"; wrapper.style.width = "100%"; wrapper.style.height = skeletonOptions.height; wrapper.style.overflow = "hidden"; wrapper.setAttribute("aria-busy", "true"); Object.assign(iframe.style, WALLET_SKELETON_IFRAME_STYLE); iframe.style.height = "100%"; let revealed = false; let appearanceReady = false; let fallbackRevealTimer: ReturnType | undefined; let currentOptions = listenerOptions; function reveal(): void { if (revealed) { return; } revealed = true; if (fallbackRevealTimer !== undefined) { clearTimeout(fallbackRevealTimer); fallbackRevealTimer = undefined; } iframe.style.opacity = "1"; iframe.style.pointerEvents = ""; skeleton.element.remove(); wrapper.removeAttribute("aria-busy"); } function tryReveal(): void { if (revealed || !appearanceReady) { return; } reveal(); } function startFallbackReveal(): void { if (revealed || fallbackRevealTimer !== undefined) { return; } fallbackRevealTimer = setTimeout(() => { reveal(); }, 1500); } const controller = attachListeners(iframe, { ...listenerOptions, onHeightChange: (height) => { currentOptions.onHeightChange?.(height); }, onAppearanceReady: () => { appearanceReady = true; tryReveal(); currentOptions.onAppearanceReady?.(); }, onIframeReady: () => { startFallbackReveal(); currentOptions.onIframeReady?.(); }, } as TOptions); wrapper.append(skeleton.element, iframe); host.appendChild(wrapper); return { iframe, update(patch) { currentOptions = { ...currentOptions, ...patch }; // Keep the wrapped reveal listeners. Forwarding these would replace // them and leave the iframe at opacity 0. const rest = { ...patch }; delete rest.onAppearanceReady; delete rest.onHeightChange; delete rest.onIframeReady; controller.update(rest); skeletonOptions = { height: currentOptions.height ?? skeletonOptions.height, borderRadius: resolveWalletButtonSkeletonBorderRadius({ iframeStyle, buttonProps: currentOptions.buttonProps, }), }; wrapper.style.height = skeletonOptions.height; if (!revealed) { skeleton.update(skeletonOptions); } }, destroy() { if (fallbackRevealTimer !== undefined) { clearTimeout(fallbackRevealTimer); } controller.destroy(); wrapper.remove(); }, }; } /** * Options accepted by {@link mountAmosCreditCardPaymentMethodForm}. */ export type AmosCreditCardPaymentMethodFormOptions = PaymentMethodFormListenerOptions & { /** * The Amos render token for the credit-card payment method form. * * It is safe to pass this to the client. Create this on * https://dashboard.amos.com. */ renderToken: string; /** * The additional fields that are required to be filled out in the * form in addition to the card number, expiration date, CVV, and * billing address fields. * * @default { cardholderName: false } */ additionalFields?: CreditCardAdditionalFields; /** * How much billing address the form collects. * * @default "country" */ billingAddressRequirement?: BillingAddressRequirement; }; /** * Controller returned by {@link mountAmosCreditCardPaymentMethodForm} * and {@link mountAmosBankAccountPaymentMethodForm}. */ export type AmosPaymentMethodFormMountController = PaymentMethodFormController & { /** * The underlying `