/// import { type Appearance, type ApplePayButtonElementProps, confirmPayment as amosConfirmPayment, confirmSetup as amosConfirmSetup, focusField as amosFocusField, resetForm as amosResetForm, validateForm as amosValidateForm, type BillingAddressRequirement, type ConfirmPaymentResult, type ConfirmSetupResult, type CreditCardAdditionalFields, ensureSkeletonStyles, type GooglePayButtonElementProps, mountAmosApplePayButton, mountAmosBankAccountPaymentMethodForm, mountAmosCreditCardPaymentMethodForm, mountAmosGooglePayButton, type PaymentMethodFormDefaultValues, type PaymentMethodFormField, resolveWalletButtonSkeletonBorderRadius, type WalletCustomerCreateAttributes, } from "@amos.com/amos-js"; import type { components } from "@amos.com/node"; import { type ComponentProps, type Ref, type RefObject, useEffect, useLayoutEffect, useRef, } from "react"; export * from "@amos.com/amos-js"; type IframeRef = RefObject | undefined; function resolveIframe(iframeRef: IframeRef): HTMLIFrameElement | null { if (!iframeRef) { return null; } return iframeRef.current ?? null; } /** * Validate the embedded card/bank iframe form before payment * confirmation. * * Resolves to `true` if the form is valid, `false` if it is not, or * `false` if the iframe does not respond within 5 seconds. */ export function validateForm({ iframeRef, }: { iframeRef: IframeRef; }): Promise { return amosValidateForm({ iframe: resolveIframe(iframeRef) }); } /** * Confirm a payment intent in the embedded iframe flow. * * Pass the embed JWT (`token`) returned by your server's * `POST /payment_intents` call. * * Resolves `{ status: "succeeded", paymentIntent }` after authorization, * `{ status: "failed", paymentIntent? }` on decline, or * `{ status: "failed", error: "timeout" }` if the iframe does not post * `CONFIRMATION_RESULT` within 15 seconds (`CONFIRM_TIMEOUT_MS`). Use * `isConfirmTimeout(result)` — a timeout is not a decline; the charge * may still settle. Embed `/confirm` aborts at 10s and posts the same * timeout result. */ export function confirmPayment({ iframeRef, token, defaultValues, }: { iframeRef: IframeRef; defaultValues?: PaymentMethodFormDefaultValues; } & Pick< components["schemas"]["EmbedToken"], "token" >): Promise { return amosConfirmPayment({ iframe: resolveIframe(iframeRef), token, defaultValues, }); } /** * Confirm a setup intent in the embedded iframe flow. Use this when * saving a payment method for future use. * * Pass the embed JWT (`token`) returned by your server's * `POST /setup_intents` call. * * Resolves `{ status: "succeeded", setupIntent }` after verification, * `{ status: "failed", setupIntent? }` on decline, or * `{ status: "failed", error: "timeout" }` if the iframe does not * respond within 15 seconds. Same `isConfirmTimeout` rule as * {@link confirmPayment}. */ export function confirmSetup({ iframeRef, token, defaultValues, }: { iframeRef: IframeRef; defaultValues?: PaymentMethodFormDefaultValues; } & Pick< components["schemas"]["EmbedToken"], "token" >): Promise { return amosConfirmSetup({ iframe: resolveIframe(iframeRef), token, defaultValues, }); } /** * Clear all field values and API errors in the embedded card/bank iframe * form. Call after a failed confirm when the customer wants to try again. */ export function resetForm({ iframeRef }: { iframeRef: IframeRef }): void { amosResetForm({ iframe: resolveIframe(iframeRef) }); } /** * Focus a named control inside the embedded card/bank iframe. No-op if * the field is not rendered, or while Plaid Embedded Institution Search * is showing. Call from a click or keydown handler. */ export function focusField({ iframeRef, field, }: { iframeRef: IframeRef; field: PaymentMethodFormField; }): void { amosFocusField({ iframe: resolveIframe(iframeRef), field }); } type ForwardedIframeRef = Ref | undefined; function setForwardedRef( ref: ForwardedIframeRef, node: HTMLIFrameElement | null, ): void { if (typeof ref === "function") { ref(node); } else if (ref) { ref.current = node; } } type IframePassthroughProps = Omit< ComponentProps<"iframe">, "src" | "title" | "name" | "role" | "allow" >; function applyIframePassthrough( iframe: HTMLIFrameElement, { style, className, id, ...rest }: IframePassthroughProps, ): void { if (className != null) { iframe.className = className; } if (id != null) { iframe.id = id; } Object.assign(iframe.style, style); for (const [key, value] of Object.entries(rest)) { if (value == null) { continue; } if (key in iframe) { Reflect.set(iframe, key, value); } else { iframe.setAttribute(key, String(value)); } } } type AmosEmbedController = { iframe: HTMLIFrameElement; update: (patch: Record) => void; destroy: () => void; }; function useAmosEmbed>({ containerRef, iframeRef, mount, options, remountDeps, iframePassthrough, updateDeps, }: { containerRef: RefObject; iframeRef: ForwardedIframeRef; mount: (container: HTMLElement, options: TOptions) => AmosEmbedController; options: TOptions; remountDeps: Array; iframePassthrough: IframePassthroughProps; updateDeps: Array; }): void { const controllerRef = useRef(null); // biome-ignore lint/correctness/useExhaustiveDependencies: remount only when remountDeps change useLayoutEffect(() => { const container = containerRef.current; if (!container) { return; } const controller = mount(container, options); controllerRef.current = controller; setForwardedRef(iframeRef, controller.iframe); applyIframePassthrough(controller.iframe, iframePassthrough); return () => { controller.destroy(); controllerRef.current = null; setForwardedRef(iframeRef, null); }; }, [...remountDeps]); // biome-ignore lint/correctness/useExhaustiveDependencies: sync listener options when updateDeps change useEffect(() => { controllerRef.current?.update(options); }, [...updateDeps]); useEffect(() => { const iframe = controllerRef.current?.iframe; if (iframe) { applyIframePassthrough(iframe, iframePassthrough); } }); } const SKELETON_ACCENT = "oklch(0.97 0 0)"; function WalletButtonSlot({ height, borderRadius, containerRef, }: { height: string; borderRadius: string; containerRef: RefObject; }) { useLayoutEffect(() => { ensureSkeletonStyles(); }, []); return (
); } type PostalCodeChangeHandler = (event: { postalCode: string | null; country: string; }) => void; /** * The installed `@amos.com/amos-js` types may not include * `onPostalCodeChange` until that package is published. The mount * parameter accepts the extra field; the runtime object still carries * it into `attachPaymentMethodFormListeners`. */ function mountCreditCardPaymentMethodForm( container: HTMLElement, options: Parameters[1] & { onPostalCodeChange?: PostalCodeChangeHandler; }, ) { return mountAmosCreditCardPaymentMethodForm(container, options); } function mountBankAccountPaymentMethodForm( container: HTMLElement, options: Parameters[1] & { onPostalCodeChange?: PostalCodeChangeHandler; }, ) { return mountAmosBankAccountPaymentMethodForm(container, options); } type AmosCreditCardPaymentMethodFormProps = IframePassthroughProps & { renderToken: string; appearance?: Appearance; /** * Called when form validity changes. `isValid` is true when all * required fields are present and valid. Does not include PCI data. */ onValidityChange?: (event: { isValid: boolean }) => void; /** * Called when the detected card brand changes. `brand` is the matched * network, or `null` when the field is empty or the digits do not * match a known brand. Does not include PCI data. */ onCardBrandChanged?: (event: { brand: | "visa" | "mastercard" | "amex" | "discover" | "diners" | "jcb" | null; }) => void; /** * Called when the customer commits a billing postal code, or clears * one. `postalCode` is null when a finished code becomes incomplete. * Incomplete keystrokes and `defaultValues` writes are omitted. */ onPostalCodeChange?: PostalCodeChangeHandler; /** * Called when the customer presses Escape in the iframe. PCI-safe — * no field values. Use this to close a host modal that contains the * iframe. Not fired while an iframe dropdown or address suggestion * list is open, or while Plaid Embedded Institution Search is * showing. */ onEscapeKeyPressed?: () => void; additionalFields?: CreditCardAdditionalFields; billingAddressRequirement?: BillingAddressRequirement; /** * Seed cardholder name and billing address. Provided keys overwrite * matching fields, including ones the customer already edited. Values * are sent on confirm even when those inputs are hidden. */ defaultValues?: PaymentMethodFormDefaultValues; }; export function AmosCreditCardPaymentMethodForm({ ref, renderToken, appearance, onValidityChange, onCardBrandChanged, onPostalCodeChange, onEscapeKeyPressed, additionalFields = { cardholderName: false }, billingAddressRequirement = "country", defaultValues, style, ...rest }: AmosCreditCardPaymentMethodFormProps) { const containerRef = useRef(null); useAmosEmbed({ containerRef, iframeRef: ref as ForwardedIframeRef, mount: mountCreditCardPaymentMethodForm, options: { renderToken, appearance, additionalFields, billingAddressRequirement, defaultValues, onValidityChange, onCardBrandChanged, onPostalCodeChange, onEscapeKeyPressed, }, remountDeps: [ renderToken, additionalFields.cardholderName, billingAddressRequirement, ], iframePassthrough: { style, ...rest }, updateDeps: [ appearance, additionalFields, billingAddressRequirement, defaultValues, onValidityChange, onCardBrandChanged, onPostalCodeChange, onEscapeKeyPressed, ], }); return
; } type AmosBankAccountPaymentMethodFormProps = IframePassthroughProps & { renderToken: string; appearance?: Appearance; /** * Called when form validity changes. `isValid` is true when all * required fields are present and valid, or when Plaid Embedded Link * has returned credentials. Does not include PCI data. */ onValidityChange?: (event: { isValid: boolean }) => void; /** * Called when the customer presses Escape in the iframe. PCI-safe — * no field values. Use this to close a host modal that contains the * iframe. Not fired while an iframe dropdown or address suggestion * list is open, or while Plaid Embedded Institution Search is * showing. */ onEscapeKeyPressed?: () => void; /** * Called when the customer commits a billing postal code, or clears * one. `postalCode` is null when a finished code becomes incomplete. * Incomplete keystrokes and `defaultValues` writes are omitted. */ onPostalCodeChange?: PostalCodeChangeHandler; billingAddressRequirement?: BillingAddressRequirement; /** * Seed account holder name and billing address. Provided keys * overwrite matching fields, including ones the customer already * edited. */ defaultValues?: PaymentMethodFormDefaultValues; /** * When true, hide the routing/account iframe and mount Plaid Embedded * Institution Search in the parent. A 350px pulse skeleton covers the * slot until Plaid's `onLoad` (1.5s fallback). Ignored when `intent` * is `"setup"` (setup always shows Plaid) or when the render token * disables verification. * * Changing this prop hides or shows Link; it does not remount the * bank form or destroy the Embedded handler. * * @default false */ requireAchVerification?: boolean; /** * `"setup"` saves a bank account for later charges and always shows * Plaid (unless the render token disables verification). `"payment"` * uses {@link AmosBankAccountPaymentMethodFormProps.requireAchVerification}. * * @default "payment" */ intent?: "payment" | "setup"; }; export function AmosBankAccountPaymentMethodForm({ ref, renderToken, appearance, onValidityChange, onPostalCodeChange, onEscapeKeyPressed, billingAddressRequirement = "country", defaultValues, requireAchVerification = false, intent = "payment", style, ...rest }: AmosBankAccountPaymentMethodFormProps) { const containerRef = useRef(null); useAmosEmbed({ containerRef, iframeRef: ref as ForwardedIframeRef, mount: mountBankAccountPaymentMethodForm, options: { renderToken, appearance, billingAddressRequirement, defaultValues, requireAchVerification, intent, onValidityChange, onPostalCodeChange, onEscapeKeyPressed, }, // `requireAchVerification` is an update, not a remount: amos-js hides // Embedded Link instead of destroying it when the flag flips. remountDeps: [renderToken, billingAddressRequirement, intent], iframePassthrough: { style, ...rest }, updateDeps: [ appearance, billingAddressRequirement, defaultValues, requireAchVerification, intent, onValidityChange, onPostalCodeChange, onEscapeKeyPressed, ], }); return
; } type AmosGooglePayButtonProps = { ref?: ForwardedIframeRef; renderToken: string; /** * Major-currency decimal string shown in the Google Pay sheet * (e.g. `"50.00"` for $50.00). Converted to cents in * `paymentIntentCreateAttributes.amount`. */ amount: string; merchantName: string; /** * Painted button height. CSS length (e.g. `"48px"`). * @default "48px" */ height?: string; /** * Native Google Pay button attributes and inner style. Omitted * fields keep Amos paint defaults (`buttonType: "plain"`, * `buttonSizeMode: "fill"`). The button fills the iframe — size the * mount slot, not the button. */ buttonProps?: GooglePayButtonElementProps; /** * Collect a phone number in the Google Pay sheet. * @default false */ phoneRequired?: boolean; /** * Collect a shipping postal address. Name, email, and billing * address are always required. * @default false */ shippingAddressRequired?: boolean; /** Props applied to the host-page `