@amos.com/amos-js 0.9.15 → 0.9.17

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.
@@ -16,7 +16,7 @@ const SKELETON_THEME_DEFAULTS: Record<string, string> = {
16
16
  "--label-font-size": "0.875rem",
17
17
  };
18
18
 
19
- const SKELETON_STYLES = `
19
+ export const SKELETON_STYLES = `
20
20
  .amos-js-form-skeleton {
21
21
  box-sizing: border-box;
22
22
  container-type: inline-size;
@@ -71,6 +71,10 @@ const SKELETON_STYLES = `
71
71
  gap: var(--control-gap);
72
72
  }
73
73
  }
74
+ .amos-js-wallet-skeleton {
75
+ flex: none;
76
+ width: 100%;
77
+ }
74
78
  @keyframes amos-js-skeleton-pulse {
75
79
  50% { opacity: 0.5; }
76
80
  }
@@ -81,7 +85,7 @@ const SKELETON_STYLES = `
81
85
  }
82
86
  `;
83
87
 
84
- function ensureSkeletonStyles(): void {
88
+ export function ensureSkeletonStyles(): void {
85
89
  if (document.getElementById(STYLE_ID)) {
86
90
  return;
87
91
  }
@@ -242,3 +246,74 @@ export function createPaymentMethodFormSkeleton(
242
246
  update: render,
243
247
  };
244
248
  }
249
+
250
+ export type WalletButtonSkeletonOptions = {
251
+ /** Painted height of the wallet button slot (CSS length). */
252
+ height: string;
253
+ /** Corner radius matching the iframe / native button. */
254
+ borderRadius?: string;
255
+ };
256
+
257
+ export type WalletButtonSkeleton = {
258
+ element: HTMLElement;
259
+ update: (options: WalletButtonSkeletonOptions) => void;
260
+ };
261
+
262
+ /**
263
+ * Host-page placeholder for Google Pay / Apple Pay: a pulsing bar at
264
+ * the button's height. Shown immediately while the iframe loads, then
265
+ * removed when appearance is ready.
266
+ */
267
+ export function createWalletButtonSkeleton(
268
+ options: WalletButtonSkeletonOptions,
269
+ ): WalletButtonSkeleton {
270
+ ensureSkeletonStyles();
271
+ const element = div("amos-js-form-skeleton-input amos-js-wallet-skeleton");
272
+ element.setAttribute("aria-hidden", "true");
273
+
274
+ function render(next: WalletButtonSkeletonOptions): void {
275
+ applyTheme(element, undefined);
276
+ element.style.height = next.height;
277
+ element.style.borderRadius = next.borderRadius ?? "4px";
278
+ }
279
+
280
+ render(options);
281
+
282
+ return {
283
+ element,
284
+ update: render,
285
+ };
286
+ }
287
+
288
+ function cssLength(value: unknown): string | undefined {
289
+ if (typeof value === "number" && Number.isFinite(value)) {
290
+ return `${value}px`;
291
+ }
292
+ if (typeof value === "string" && value.trim() !== "") {
293
+ return value.trim();
294
+ }
295
+ return undefined;
296
+ }
297
+
298
+ /**
299
+ * Corner radius for a wallet-button skeleton. Prefers host iframe
300
+ * chrome, then native Google / Apple button radius, then 4px.
301
+ */
302
+ export function resolveWalletButtonSkeletonBorderRadius({
303
+ iframeStyle,
304
+ buttonProps,
305
+ }: {
306
+ iframeStyle?: { borderRadius?: string | number };
307
+ buttonProps?: {
308
+ buttonRadius?: number;
309
+ style?: Record<string, string | number | undefined>;
310
+ };
311
+ }): string {
312
+ return (
313
+ cssLength(iframeStyle?.borderRadius) ??
314
+ cssLength(buttonProps?.buttonRadius) ??
315
+ cssLength(buttonProps?.style?.["borderRadius"]) ??
316
+ cssLength(buttonProps?.style?.["--apple-pay-button-border-radius"]) ??
317
+ "4px"
318
+ );
319
+ }
package/src/google-pay.ts CHANGED
@@ -34,7 +34,11 @@ export function getGooglePayButtonInitialHeight(): string {
34
34
  * Options accepted by {@link attachGooglePayButtonListeners}.
35
35
  */
36
36
  export type GooglePayButtonListenerOptions = {
37
- /** The amount of the payment, in the same format passed in props. */
37
+ /**
38
+ * Major-currency decimal string shown in the Google Pay sheet
39
+ * (e.g. `"50.00"` for $50.00). Converted to cents in
40
+ * `paymentIntentCreateAttributes.amount`.
41
+ */
38
42
  amount: string;
39
43
  /** A user-visible merchant name. */
40
44
  merchantName: string;
package/src/index.ts CHANGED
@@ -9,7 +9,20 @@ export {
9
9
  getApplePayButtonInitialHeight,
10
10
  getApplePayButtonSrc,
11
11
  } from "./apple-pay";
12
-
12
+ export type {
13
+ PaymentMethodFormSkeleton,
14
+ PaymentMethodFormSkeletonKind,
15
+ PaymentMethodFormSkeletonOptions,
16
+ WalletButtonSkeleton,
17
+ WalletButtonSkeletonOptions,
18
+ } from "./form-skeleton";
19
+ export {
20
+ createPaymentMethodFormSkeleton,
21
+ createWalletButtonSkeleton,
22
+ ensureSkeletonStyles,
23
+ resolveWalletButtonSkeletonBorderRadius,
24
+ SKELETON_STYLES,
25
+ } from "./form-skeleton";
13
26
  export type {
14
27
  FormattedGooglePayPaymentData,
15
28
  GooglePayButtonController,
@@ -21,7 +34,6 @@ export {
21
34
  getGooglePayButtonInitialHeight,
22
35
  getGooglePayButtonSrc,
23
36
  } from "./google-pay";
24
-
25
37
  export { decodeJwt, getEmbedOrigin } from "./jwt";
26
38
 
27
39
  export {
package/src/messaging.ts CHANGED
@@ -114,7 +114,8 @@ export function updateGooglePayButton({
114
114
  }
115
115
 
116
116
  /**
117
- * Push the express-checkout amount into the embedded Google Pay iframe.
117
+ * Push the express-checkout amount into the embedded wallet iframe.
118
+ * `amount` is a major-currency decimal string (e.g. `"50.00"` for $50.00).
118
119
  */
119
120
  export function updateAmount({
120
121
  iframe,
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,
@@ -59,6 +63,19 @@ const WALLET_IFRAME_STYLE: Partial<CSSStyleDeclaration> = {
59
63
  border: "0",
60
64
  };
61
65
 
66
+ const WALLET_SKELETON_IFRAME_STYLE: Partial<CSSStyleDeclaration> = {
67
+ position: "absolute",
68
+ top: "0",
69
+ left: "0",
70
+ width: "100%",
71
+ height: "100%",
72
+ margin: "0",
73
+ opacity: "0",
74
+ // Do not interpolate opacity while the skeleton is showing.
75
+ transition: "none",
76
+ pointerEvents: "none",
77
+ };
78
+
62
79
  const SKELETON_IFRAME_STYLE: Partial<CSSStyleDeclaration> = {
63
80
  position: "absolute",
64
81
  top: "0",
@@ -231,6 +248,141 @@ function mountPaymentMethodFormWithSkeleton({
231
248
  };
232
249
  }
233
250
 
251
+ type WalletListenerOptions = {
252
+ height?: string;
253
+ onHeightChange?: (height: string) => void;
254
+ onAppearanceReady?: () => void;
255
+ buttonProps?: {
256
+ buttonRadius?: number;
257
+ style?: Record<string, string | number | undefined>;
258
+ };
259
+ };
260
+
261
+ type WalletListenerController<TOptions> = {
262
+ update: (patch: Partial<TOptions>) => void;
263
+ destroy: () => void;
264
+ };
265
+
266
+ function mountWalletButtonWithSkeleton<TOptions extends WalletListenerOptions>({
267
+ host,
268
+ iframe,
269
+ listenerOptions,
270
+ iframeStyle,
271
+ attachListeners,
272
+ }: {
273
+ host: HTMLElement;
274
+ iframe: HTMLIFrameElement;
275
+ listenerOptions: TOptions;
276
+ iframeStyle?: Partial<CSSStyleDeclaration>;
277
+ attachListeners: (
278
+ iframe: HTMLIFrameElement,
279
+ options: TOptions,
280
+ ) => WalletListenerController<TOptions>;
281
+ }): {
282
+ iframe: HTMLIFrameElement;
283
+ update: (patch: Partial<TOptions>) => void;
284
+ destroy: () => void;
285
+ } {
286
+ const initialHeight = listenerOptions.height ?? "48px";
287
+ let skeletonOptions: WalletButtonSkeletonOptions = {
288
+ height: initialHeight,
289
+ borderRadius: resolveWalletButtonSkeletonBorderRadius({
290
+ iframeStyle,
291
+ buttonProps: listenerOptions.buttonProps,
292
+ }),
293
+ };
294
+ const skeleton: WalletButtonSkeleton =
295
+ createWalletButtonSkeleton(skeletonOptions);
296
+
297
+ const wrapper = document.createElement("div");
298
+ wrapper.style.position = "relative";
299
+ wrapper.style.width = "100%";
300
+ wrapper.style.height = skeletonOptions.height;
301
+ wrapper.style.overflow = "hidden";
302
+ wrapper.setAttribute("aria-busy", "true");
303
+
304
+ Object.assign(iframe.style, WALLET_SKELETON_IFRAME_STYLE);
305
+ iframe.style.height = "100%";
306
+
307
+ wrapper.append(skeleton.element, iframe);
308
+ host.appendChild(wrapper);
309
+
310
+ let revealed = false;
311
+ let appearanceReady = false;
312
+ let fallbackRevealTimer: ReturnType<typeof setTimeout> | undefined;
313
+ let currentOptions = listenerOptions;
314
+
315
+ function reveal(): void {
316
+ if (revealed) {
317
+ return;
318
+ }
319
+ revealed = true;
320
+ if (fallbackRevealTimer !== undefined) {
321
+ clearTimeout(fallbackRevealTimer);
322
+ fallbackRevealTimer = undefined;
323
+ }
324
+
325
+ iframe.style.opacity = "1";
326
+ iframe.style.pointerEvents = "";
327
+ skeleton.element.remove();
328
+ wrapper.removeAttribute("aria-busy");
329
+ }
330
+
331
+ function tryReveal(): void {
332
+ if (revealed || !appearanceReady) {
333
+ return;
334
+ }
335
+ reveal();
336
+ }
337
+
338
+ const controller = attachListeners(iframe, {
339
+ ...listenerOptions,
340
+ onHeightChange: (height) => {
341
+ currentOptions.onHeightChange?.(height);
342
+ },
343
+ onAppearanceReady: () => {
344
+ appearanceReady = true;
345
+ tryReveal();
346
+ currentOptions.onAppearanceReady?.();
347
+ },
348
+ } as TOptions);
349
+
350
+ fallbackRevealTimer = setTimeout(() => {
351
+ reveal();
352
+ }, 1500);
353
+
354
+ return {
355
+ iframe,
356
+ update(patch) {
357
+ currentOptions = { ...currentOptions, ...patch };
358
+ // Keep the wrapped reveal listeners. Forwarding these would replace
359
+ // them and leave the iframe at opacity 0.
360
+ const rest = { ...patch };
361
+ delete rest.onAppearanceReady;
362
+ delete rest.onHeightChange;
363
+ controller.update(rest);
364
+ skeletonOptions = {
365
+ height: currentOptions.height ?? skeletonOptions.height,
366
+ borderRadius: resolveWalletButtonSkeletonBorderRadius({
367
+ iframeStyle,
368
+ buttonProps: currentOptions.buttonProps,
369
+ }),
370
+ };
371
+ wrapper.style.height = skeletonOptions.height;
372
+ if (!revealed) {
373
+ skeleton.update(skeletonOptions);
374
+ }
375
+ },
376
+ destroy() {
377
+ if (fallbackRevealTimer !== undefined) {
378
+ clearTimeout(fallbackRevealTimer);
379
+ }
380
+ controller.destroy();
381
+ wrapper.remove();
382
+ },
383
+ };
384
+ }
385
+
234
386
  /**
235
387
  * Options accepted by {@link mountAmosCreditCardPaymentMethodForm}.
236
388
  */
@@ -420,6 +572,9 @@ export type AmosGooglePayButtonMountController = GooglePayButtonController & {
420
572
  * Mount the secure Google Pay button (express checkout) into a
421
573
  * container element. Returns a controller exposing the underlying
422
574
  * iframe, an `update()` method, and a `destroy()` method.
575
+ *
576
+ * A button-shaped skeleton is shown immediately and replaced by the
577
+ * iframe once appearance is applied.
423
578
  */
424
579
  export function mountAmosGooglePayButton(
425
580
  container: Container,
@@ -439,28 +594,14 @@ export function mountAmosGooglePayButton(
439
594
  style: WALLET_IFRAME_STYLE,
440
595
  });
441
596
  Object.assign(iframe.style, iframeStyle);
442
- host.appendChild(iframe);
443
597
 
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 {
598
+ return mountWalletButtonWithSkeleton({
599
+ host,
457
600
  iframe,
458
- update: controller.update,
459
- destroy() {
460
- controller.destroy();
461
- iframe.remove();
462
- },
463
- };
601
+ listenerOptions,
602
+ iframeStyle,
603
+ attachListeners: attachGooglePayButtonListeners,
604
+ });
464
605
  }
465
606
 
466
607
  /**
@@ -497,6 +638,9 @@ export type AmosApplePayButtonMountController = ApplePayButtonController & {
497
638
  * Mount the secure Apple Pay button (express checkout) into a
498
639
  * container element. Returns a controller exposing the underlying
499
640
  * iframe, an `update()` method, and a `destroy()` method.
641
+ *
642
+ * A button-shaped skeleton is shown immediately and replaced by the
643
+ * iframe once appearance is applied.
500
644
  */
501
645
  export function mountAmosApplePayButton(
502
646
  container: Container,
@@ -516,26 +660,12 @@ export function mountAmosApplePayButton(
516
660
  style: WALLET_IFRAME_STYLE,
517
661
  });
518
662
  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
663
 
533
- return {
664
+ return mountWalletButtonWithSkeleton({
665
+ host,
534
666
  iframe,
535
- update: controller.update,
536
- destroy() {
537
- controller.destroy();
538
- iframe.remove();
539
- },
540
- };
667
+ listenerOptions,
668
+ iframeStyle,
669
+ attachListeners: attachApplePayButtonListeners,
670
+ });
541
671
  }
package/src/types.ts CHANGED
@@ -406,7 +406,10 @@ export type Message =
406
406
  height: string;
407
407
  }
408
408
  | {
409
- /** Parent → embed: express-checkout amount changed. */
409
+ /**
410
+ * Parent → embed: express-checkout amount changed. Major-currency
411
+ * decimal string (e.g. `"50.00"` for $50.00).
412
+ */
410
413
  type: "UPDATE_AMOUNT";
411
414
  amount: string;
412
415
  }