@amos.com/amos-js 0.9.16 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mount.ts CHANGED
@@ -7,7 +7,11 @@ import {
7
7
  } from "./apple-pay";
8
8
  import {
9
9
  createPaymentMethodFormSkeleton,
10
+ createWalletButtonSkeleton,
10
11
  type PaymentMethodFormSkeletonOptions,
12
+ resolveWalletButtonSkeletonBorderRadius,
13
+ type WalletButtonSkeleton,
14
+ type WalletButtonSkeletonOptions,
11
15
  } from "./form-skeleton";
12
16
  import {
13
17
  attachGooglePayButtonListeners,
@@ -27,6 +31,7 @@ import {
27
31
  type PaymentMethodFormController,
28
32
  type PaymentMethodFormListenerOptions,
29
33
  } from "./payment-method-form";
34
+ import { attachPlaidBankUi } from "./plaid-bank-ui";
30
35
 
31
36
  type Container = HTMLElement | string;
32
37
 
@@ -59,6 +64,19 @@ const WALLET_IFRAME_STYLE: Partial<CSSStyleDeclaration> = {
59
64
  border: "0",
60
65
  };
61
66
 
67
+ const WALLET_SKELETON_IFRAME_STYLE: Partial<CSSStyleDeclaration> = {
68
+ position: "absolute",
69
+ top: "0",
70
+ left: "0",
71
+ width: "100%",
72
+ height: "100%",
73
+ margin: "0",
74
+ opacity: "0",
75
+ // Do not interpolate opacity while the skeleton is showing.
76
+ transition: "none",
77
+ pointerEvents: "none",
78
+ };
79
+
62
80
  const SKELETON_IFRAME_STYLE: Partial<CSSStyleDeclaration> = {
63
81
  position: "absolute",
64
82
  top: "0",
@@ -231,6 +249,141 @@ function mountPaymentMethodFormWithSkeleton({
231
249
  };
232
250
  }
233
251
 
252
+ type WalletListenerOptions = {
253
+ height?: string;
254
+ onHeightChange?: (height: string) => void;
255
+ onAppearanceReady?: () => void;
256
+ buttonProps?: {
257
+ buttonRadius?: number;
258
+ style?: Record<string, string | number | undefined>;
259
+ };
260
+ };
261
+
262
+ type WalletListenerController<TOptions> = {
263
+ update: (patch: Partial<TOptions>) => void;
264
+ destroy: () => void;
265
+ };
266
+
267
+ function mountWalletButtonWithSkeleton<TOptions extends WalletListenerOptions>({
268
+ host,
269
+ iframe,
270
+ listenerOptions,
271
+ iframeStyle,
272
+ attachListeners,
273
+ }: {
274
+ host: HTMLElement;
275
+ iframe: HTMLIFrameElement;
276
+ listenerOptions: TOptions;
277
+ iframeStyle?: Partial<CSSStyleDeclaration>;
278
+ attachListeners: (
279
+ iframe: HTMLIFrameElement,
280
+ options: TOptions,
281
+ ) => WalletListenerController<TOptions>;
282
+ }): {
283
+ iframe: HTMLIFrameElement;
284
+ update: (patch: Partial<TOptions>) => void;
285
+ destroy: () => void;
286
+ } {
287
+ const initialHeight = listenerOptions.height ?? "48px";
288
+ let skeletonOptions: WalletButtonSkeletonOptions = {
289
+ height: initialHeight,
290
+ borderRadius: resolveWalletButtonSkeletonBorderRadius({
291
+ iframeStyle,
292
+ buttonProps: listenerOptions.buttonProps,
293
+ }),
294
+ };
295
+ const skeleton: WalletButtonSkeleton =
296
+ createWalletButtonSkeleton(skeletonOptions);
297
+
298
+ const wrapper = document.createElement("div");
299
+ wrapper.style.position = "relative";
300
+ wrapper.style.width = "100%";
301
+ wrapper.style.height = skeletonOptions.height;
302
+ wrapper.style.overflow = "hidden";
303
+ wrapper.setAttribute("aria-busy", "true");
304
+
305
+ Object.assign(iframe.style, WALLET_SKELETON_IFRAME_STYLE);
306
+ iframe.style.height = "100%";
307
+
308
+ wrapper.append(skeleton.element, iframe);
309
+ host.appendChild(wrapper);
310
+
311
+ let revealed = false;
312
+ let appearanceReady = false;
313
+ let fallbackRevealTimer: ReturnType<typeof setTimeout> | undefined;
314
+ let currentOptions = listenerOptions;
315
+
316
+ function reveal(): void {
317
+ if (revealed) {
318
+ return;
319
+ }
320
+ revealed = true;
321
+ if (fallbackRevealTimer !== undefined) {
322
+ clearTimeout(fallbackRevealTimer);
323
+ fallbackRevealTimer = undefined;
324
+ }
325
+
326
+ iframe.style.opacity = "1";
327
+ iframe.style.pointerEvents = "";
328
+ skeleton.element.remove();
329
+ wrapper.removeAttribute("aria-busy");
330
+ }
331
+
332
+ function tryReveal(): void {
333
+ if (revealed || !appearanceReady) {
334
+ return;
335
+ }
336
+ reveal();
337
+ }
338
+
339
+ const controller = attachListeners(iframe, {
340
+ ...listenerOptions,
341
+ onHeightChange: (height) => {
342
+ currentOptions.onHeightChange?.(height);
343
+ },
344
+ onAppearanceReady: () => {
345
+ appearanceReady = true;
346
+ tryReveal();
347
+ currentOptions.onAppearanceReady?.();
348
+ },
349
+ } as TOptions);
350
+
351
+ fallbackRevealTimer = setTimeout(() => {
352
+ reveal();
353
+ }, 1500);
354
+
355
+ return {
356
+ iframe,
357
+ update(patch) {
358
+ currentOptions = { ...currentOptions, ...patch };
359
+ // Keep the wrapped reveal listeners. Forwarding these would replace
360
+ // them and leave the iframe at opacity 0.
361
+ const rest = { ...patch };
362
+ delete rest.onAppearanceReady;
363
+ delete rest.onHeightChange;
364
+ controller.update(rest);
365
+ skeletonOptions = {
366
+ height: currentOptions.height ?? skeletonOptions.height,
367
+ borderRadius: resolveWalletButtonSkeletonBorderRadius({
368
+ iframeStyle,
369
+ buttonProps: currentOptions.buttonProps,
370
+ }),
371
+ };
372
+ wrapper.style.height = skeletonOptions.height;
373
+ if (!revealed) {
374
+ skeleton.update(skeletonOptions);
375
+ }
376
+ },
377
+ destroy() {
378
+ if (fallbackRevealTimer !== undefined) {
379
+ clearTimeout(fallbackRevealTimer);
380
+ }
381
+ controller.destroy();
382
+ wrapper.remove();
383
+ },
384
+ };
385
+ }
386
+
234
387
  /**
235
388
  * Options accepted by {@link mountAmosCreditCardPaymentMethodForm}.
236
389
  */
@@ -273,6 +426,18 @@ export type AmosPaymentMethodFormMountController =
273
426
  iframe: HTMLIFrameElement;
274
427
  };
275
428
 
429
+ /**
430
+ * Controller returned by {@link mountAmosBankAccountPaymentMethodForm}.
431
+ * `update()` also accepts `amount` (major-currency decimal string) when
432
+ * the charge changes.
433
+ */
434
+ export type AmosBankAccountPaymentMethodFormMountController = Omit<
435
+ AmosPaymentMethodFormMountController,
436
+ "update"
437
+ > & {
438
+ update: (patch: Partial<AmosBankAccountPaymentMethodFormOptions>) => void;
439
+ };
440
+
276
441
  /**
277
442
  * Mount the secure credit-card payment method form into a container
278
443
  * element. Returns a controller exposing the underlying iframe, an
@@ -342,6 +507,14 @@ export type AmosBankAccountPaymentMethodFormOptions =
342
507
  * @default "country"
343
508
  */
344
509
  billingAddressRequirement?: BillingAddressRequirement;
510
+ /**
511
+ * Charge amount as a major-currency decimal string (e.g. `"50.00"`
512
+ * for $50.00), the same format as Google Pay / Apple Pay. Compared
513
+ * to the merchant ACH threshold fetched by the iframe. Omit for
514
+ * setup intents or when the charge is unknown — if a threshold is
515
+ * set, Plaid is required.
516
+ */
517
+ amount?: string;
345
518
  };
346
519
 
347
520
  /**
@@ -349,8 +522,12 @@ export type AmosBankAccountPaymentMethodFormOptions =
349
522
  * element. Returns a controller exposing the underlying iframe, an
350
523
  * `update()` method, and a `destroy()` method.
351
524
  *
352
- * A field-shaped skeleton is shown immediately and replaced by the
353
- * iframe once appearance is applied.
525
+ * When the iframe reports an ACH threshold and `amount` meets it (or
526
+ * `amount` is omitted), a Connect bank button is rendered in the parent
527
+ * document and Plaid Link is opened on click. The button uses the same
528
+ * `appearance.themeVariables` as the iframe (and inherits host-page
529
+ * tokens when those variables are unset). Otherwise a field-shaped
530
+ * skeleton is shown and replaced by the iframe once appearance is applied.
354
531
  *
355
532
  * Use the returned `controller.iframe` when calling
356
533
  * {@link validateForm}, {@link confirmPaymentIntent}, or
@@ -359,11 +536,12 @@ export type AmosBankAccountPaymentMethodFormOptions =
359
536
  export function mountAmosBankAccountPaymentMethodForm(
360
537
  container: Container,
361
538
  options: AmosBankAccountPaymentMethodFormOptions,
362
- ): AmosPaymentMethodFormMountController {
539
+ ): AmosBankAccountPaymentMethodFormMountController {
363
540
  const host = resolveContainer(container);
364
541
  const {
365
542
  renderToken,
366
543
  billingAddressRequirement = "country",
544
+ amount,
367
545
  ...listenerOptions
368
546
  } = options;
369
547
 
@@ -374,7 +552,7 @@ export function mountAmosBankAccountPaymentMethodForm(
374
552
  height: getBankAccountFormInitialHeight(billingAddressRequirement),
375
553
  });
376
554
 
377
- return mountPaymentMethodFormWithSkeleton({
555
+ const iframeMount = mountPaymentMethodFormWithSkeleton({
378
556
  host,
379
557
  iframe,
380
558
  listenerOptions,
@@ -384,6 +562,28 @@ export function mountAmosBankAccountPaymentMethodForm(
384
562
  billingAddressRequirement,
385
563
  },
386
564
  });
565
+
566
+ const plaidUi = attachPlaidBankUi({
567
+ host,
568
+ iframe,
569
+ options: {
570
+ amount,
571
+ appearance: listenerOptions.appearance,
572
+ onValidityChange: listenerOptions.onValidityChange,
573
+ },
574
+ });
575
+
576
+ return {
577
+ iframe,
578
+ update(patch) {
579
+ iframeMount.update(patch as Partial<PaymentMethodFormListenerOptions>);
580
+ plaidUi.update(patch);
581
+ },
582
+ destroy() {
583
+ plaidUi.destroy();
584
+ iframeMount.destroy();
585
+ },
586
+ };
387
587
  }
388
588
 
389
589
  /**
@@ -420,6 +620,9 @@ export type AmosGooglePayButtonMountController = GooglePayButtonController & {
420
620
  * Mount the secure Google Pay button (express checkout) into a
421
621
  * container element. Returns a controller exposing the underlying
422
622
  * iframe, an `update()` method, and a `destroy()` method.
623
+ *
624
+ * A button-shaped skeleton is shown immediately and replaced by the
625
+ * iframe once appearance is applied.
423
626
  */
424
627
  export function mountAmosGooglePayButton(
425
628
  container: Container,
@@ -439,28 +642,14 @@ export function mountAmosGooglePayButton(
439
642
  style: WALLET_IFRAME_STYLE,
440
643
  });
441
644
  Object.assign(iframe.style, iframeStyle);
442
- host.appendChild(iframe);
443
645
 
444
- const controller = attachGooglePayButtonListeners(iframe, {
445
- ...listenerOptions,
446
- onHeightChange: (height) => {
447
- iframe.style.height = height;
448
- listenerOptions.onHeightChange?.(height);
449
- },
450
- onAppearanceReady: () => {
451
- iframe.style.opacity = "1";
452
- listenerOptions.onAppearanceReady?.();
453
- },
454
- });
455
-
456
- return {
646
+ return mountWalletButtonWithSkeleton({
647
+ host,
457
648
  iframe,
458
- update: controller.update,
459
- destroy() {
460
- controller.destroy();
461
- iframe.remove();
462
- },
463
- };
649
+ listenerOptions,
650
+ iframeStyle,
651
+ attachListeners: attachGooglePayButtonListeners,
652
+ });
464
653
  }
465
654
 
466
655
  /**
@@ -497,6 +686,9 @@ export type AmosApplePayButtonMountController = ApplePayButtonController & {
497
686
  * Mount the secure Apple Pay button (express checkout) into a
498
687
  * container element. Returns a controller exposing the underlying
499
688
  * iframe, an `update()` method, and a `destroy()` method.
689
+ *
690
+ * A button-shaped skeleton is shown immediately and replaced by the
691
+ * iframe once appearance is applied.
500
692
  */
501
693
  export function mountAmosApplePayButton(
502
694
  container: Container,
@@ -516,26 +708,12 @@ export function mountAmosApplePayButton(
516
708
  style: WALLET_IFRAME_STYLE,
517
709
  });
518
710
  Object.assign(iframe.style, iframeStyle);
519
- host.appendChild(iframe);
520
-
521
- const controller = attachApplePayButtonListeners(iframe, {
522
- ...listenerOptions,
523
- onHeightChange: (height) => {
524
- iframe.style.height = height;
525
- listenerOptions.onHeightChange?.(height);
526
- },
527
- onAppearanceReady: () => {
528
- iframe.style.opacity = "1";
529
- listenerOptions.onAppearanceReady?.();
530
- },
531
- });
532
711
 
533
- return {
712
+ return mountWalletButtonWithSkeleton({
713
+ host,
534
714
  iframe,
535
- update: controller.update,
536
- destroy() {
537
- controller.destroy();
538
- iframe.remove();
539
- },
540
- };
715
+ listenerOptions,
716
+ iframeStyle,
717
+ attachListeners: attachApplePayButtonListeners,
718
+ });
541
719
  }
@@ -3,6 +3,7 @@ import {
3
3
  sendParentReadyMessage,
4
4
  updateAppearance as sendUpdateAppearance,
5
5
  } from "./messaging";
6
+ import { getBankPlaidSession } from "./plaid-session";
6
7
  import type {
7
8
  Appearance,
8
9
  ConfirmationResult,
@@ -203,6 +204,9 @@ export function attachPaymentMethodFormListeners(
203
204
  break;
204
205
 
205
206
  case "FORM_VALIDITY_CHANGE":
207
+ if (getBankPlaidSession(iframe)?.requiresVerification) {
208
+ break;
209
+ }
206
210
  current.onValidityChange?.({ isValid: event.data.isValid });
207
211
  break;
208
212