@amos.com/amos-js 0.1.0

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.
@@ -0,0 +1,175 @@
1
+ import type { components } from "@amos.com/node";
2
+ import { decodeJwt } from "./jwt";
3
+ import { type Appearance, createMessage, type Message } from "./types";
4
+
5
+ type Iframe = HTMLIFrameElement | null | undefined;
6
+
7
+ /**
8
+ * Notify the embedded iframe that the host page is ready to receive
9
+ * messages. The SDK calls this internally after receiving an
10
+ * `IFRAME_READY` event from the iframe.
11
+ */
12
+ export function sendParentReadyMessage(iframe: Iframe): void {
13
+ iframe?.contentWindow?.postMessage(
14
+ createMessage({ type: "PARENT_ACKNOWLEDGED_IFRAME_READY" }),
15
+ "*",
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Push appearance overrides into the embedded iframe.
21
+ *
22
+ * Only the provided `themeVariables` are sent; omitted variables keep
23
+ * their defaults.
24
+ */
25
+ export function updateAppearance({
26
+ iframe,
27
+ appearance = {},
28
+ }: {
29
+ iframe: Iframe;
30
+ appearance?: Appearance;
31
+ }): void {
32
+ iframe?.contentWindow?.postMessage(
33
+ createMessage({ type: "UPDATE_APPEARANCE", appearance }),
34
+ "*",
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Push the express-checkout amount into the embedded Google Pay iframe.
40
+ */
41
+ export function updateAmount({
42
+ iframe,
43
+ amount,
44
+ }: {
45
+ iframe: Iframe;
46
+ amount: string;
47
+ }): void {
48
+ iframe?.contentWindow?.postMessage(
49
+ createMessage({ type: "UPDATE_AMOUNT", amount }),
50
+ "*",
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Push the user-visible merchant name into the embedded Google Pay
56
+ * iframe.
57
+ */
58
+ export function updateMerchantName({
59
+ iframe,
60
+ merchantName,
61
+ }: {
62
+ iframe: Iframe;
63
+ merchantName: string;
64
+ }): void {
65
+ iframe?.contentWindow?.postMessage(
66
+ createMessage({ type: "UPDATE_MERCHANT_NAME", merchantName }),
67
+ "*",
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Ask the embedded credit-card or bank-account iframe form to validate
73
+ * its inputs.
74
+ *
75
+ * Resolves to `true` if the form is valid, `false` if it is not, or
76
+ * `false` if the iframe does not respond within 5 seconds.
77
+ */
78
+ export function validateForm({ iframe }: { iframe: Iframe }): Promise<boolean> {
79
+ const requestId = crypto.randomUUID();
80
+
81
+ return new Promise((resolve) => {
82
+ iframe?.contentWindow?.postMessage(
83
+ createMessage({ type: "VALIDATE_FORM", requestId }),
84
+ "*",
85
+ );
86
+
87
+ const timeoutId = setTimeout(() => {
88
+ window.removeEventListener("message", handleMessage);
89
+ resolve(false);
90
+ }, 5000);
91
+
92
+ function handleMessage(event: MessageEvent<Message>) {
93
+ if (
94
+ event.data.type === "VALIDATE_FORM" &&
95
+ event.data.requestId === requestId
96
+ ) {
97
+ window.removeEventListener("message", handleMessage);
98
+ clearTimeout(timeoutId);
99
+ resolve(event.data.isValid ?? false);
100
+ }
101
+ }
102
+
103
+ window.addEventListener("message", handleMessage);
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Confirm a payment intent in the embedded iframe flow.
109
+ *
110
+ * Pass the embed JWT (`token`) returned by your server's
111
+ * `POST /payment_intents` call. The matching `payment_intent_id` is
112
+ * extracted from the JWT payload and forwarded to the iframe.
113
+ */
114
+ export function confirmPaymentIntent({
115
+ iframe,
116
+ token,
117
+ }: {
118
+ iframe: Iframe;
119
+ } & Pick<components["schemas"]["EmbedToken"], "token">): void {
120
+ const { payment_intent_id: id }: components["schemas"]["EmbedTokenJwt"] =
121
+ decodeJwt(token).payload;
122
+ iframe?.contentWindow?.postMessage(
123
+ createMessage({
124
+ type: "CONFIRM_PAYMENT_INTENT",
125
+ token,
126
+ id: id ?? undefined,
127
+ }),
128
+ "*",
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Confirm a setup intent in the embedded iframe flow.
134
+ *
135
+ * Pass the embed JWT (`token`) returned by your server's
136
+ * `POST /setup_intents` call. The matching `setup_intent_id` is
137
+ * extracted from the JWT payload and forwarded to the iframe.
138
+ */
139
+ export function confirmSetupIntent({
140
+ iframe,
141
+ token,
142
+ }: {
143
+ iframe: Iframe;
144
+ } & Pick<components["schemas"]["EmbedToken"], "token">): void {
145
+ const { setup_intent_id: id }: components["schemas"]["EmbedTokenJwt"] =
146
+ decodeJwt(token).payload;
147
+ iframe?.contentWindow?.postMessage(
148
+ createMessage({
149
+ type: "CONFIRM_SETUP_INTENT",
150
+ token,
151
+ id: id ?? undefined,
152
+ }),
153
+ "*",
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Notify the iframe that confirmation failed (used by express-checkout
159
+ * flows after `onInitiatePaymentIntentRequest` rejects).
160
+ */
161
+ export function sendConfirmationFailed({
162
+ iframe,
163
+ errorMessage,
164
+ }: {
165
+ iframe: Iframe;
166
+ errorMessage: string;
167
+ }): void {
168
+ iframe?.contentWindow?.postMessage(
169
+ createMessage({
170
+ type: "CONFIRMATION_FAILED",
171
+ errorMessage,
172
+ }),
173
+ "*",
174
+ );
175
+ }
package/src/mount.ts ADDED
@@ -0,0 +1,278 @@
1
+ import {
2
+ attachGooglePayButtonListeners,
3
+ type GooglePayButtonController,
4
+ type GooglePayButtonListenerOptions,
5
+ getGooglePayButtonInitialHeight,
6
+ getGooglePayButtonSrc,
7
+ } from "./google-pay";
8
+ import {
9
+ attachPaymentMethodFormListeners,
10
+ type CreditCardAdditionalFields,
11
+ getBankAccountFormInitialHeight,
12
+ getBankAccountFormSrc,
13
+ getCreditCardFormInitialHeight,
14
+ getCreditCardFormSrc,
15
+ type PaymentMethodFormController,
16
+ type PaymentMethodFormListenerOptions,
17
+ } from "./payment-method-form";
18
+
19
+ type Container = HTMLElement | string;
20
+
21
+ function resolveContainer(container: Container): HTMLElement {
22
+ if (typeof container === "string") {
23
+ const element = document.querySelector(container);
24
+ if (!(element instanceof HTMLElement)) {
25
+ throw new Error(
26
+ `[amos-js] Container "${container}" did not match any HTMLElement.`,
27
+ );
28
+ }
29
+ return element;
30
+ }
31
+ return container;
32
+ }
33
+
34
+ const SHARED_IFRAME_STYLE: Partial<CSSStyleDeclaration> = {
35
+ width: "calc(100% + 8px)",
36
+ transition: "opacity 150ms ease-in, height 200ms ease-in-out",
37
+ margin: "0 -4px",
38
+ opacity: "0",
39
+ border: "0",
40
+ };
41
+
42
+ function createIframe({
43
+ src,
44
+ title,
45
+ name,
46
+ height,
47
+ allow,
48
+ }: {
49
+ src: string;
50
+ title: string;
51
+ name: string;
52
+ height: string;
53
+ allow?: string;
54
+ }): HTMLIFrameElement {
55
+ const iframe = document.createElement("iframe");
56
+ iframe.src = src;
57
+ iframe.title = title;
58
+ iframe.name = name;
59
+ iframe.setAttribute("role", "presentation");
60
+ iframe.scrolling = "no";
61
+ if (allow) {
62
+ iframe.allow = allow;
63
+ }
64
+ Object.assign(iframe.style, SHARED_IFRAME_STYLE, { height });
65
+ return iframe;
66
+ }
67
+
68
+ /**
69
+ * Options accepted by {@link mountAmosCreditCardPaymentMethodForm}.
70
+ */
71
+ export type AmosCreditCardPaymentMethodFormOptions =
72
+ PaymentMethodFormListenerOptions & {
73
+ /**
74
+ * The Amos render token for the credit-card payment method form.
75
+ *
76
+ * It is safe to pass this to the client. Create this on
77
+ * https://dashboard.amos.com.
78
+ */
79
+ renderToken: string;
80
+ /**
81
+ * The additional fields that are required to be filled out in the
82
+ * form in addition to the card number, expiration date, CVV,
83
+ * country, and postal code.
84
+ *
85
+ * @default { cardholderName: false }
86
+ */
87
+ additionalFields?: CreditCardAdditionalFields;
88
+ };
89
+
90
+ /**
91
+ * Controller returned by {@link mountAmosCreditCardPaymentMethodForm}
92
+ * and {@link mountAmosBankAccountPaymentMethodForm}.
93
+ */
94
+ export type AmosPaymentMethodFormMountController =
95
+ PaymentMethodFormController & {
96
+ /**
97
+ * The underlying `<iframe>` element. Pass this as the `iframe`
98
+ * argument to {@link validateForm}, {@link confirmPaymentIntent}, or
99
+ * {@link confirmSetupIntent}.
100
+ */
101
+ iframe: HTMLIFrameElement;
102
+ };
103
+
104
+ /**
105
+ * Mount the secure credit-card payment method form into a container
106
+ * element. Returns a controller exposing the underlying iframe, an
107
+ * `update()` method, and a `destroy()` method.
108
+ *
109
+ * Use the returned `controller.iframe` when calling
110
+ * {@link validateForm}, {@link confirmPaymentIntent}, or
111
+ * {@link confirmSetupIntent}.
112
+ */
113
+ export function mountAmosCreditCardPaymentMethodForm(
114
+ container: Container,
115
+ options: AmosCreditCardPaymentMethodFormOptions,
116
+ ): AmosPaymentMethodFormMountController {
117
+ const host = resolveContainer(container);
118
+ const {
119
+ renderToken,
120
+ additionalFields = { cardholderName: false },
121
+ ...listenerOptions
122
+ } = options;
123
+
124
+ const iframe = createIframe({
125
+ src: getCreditCardFormSrc(renderToken, additionalFields),
126
+ title: "Secure credit card payment method form powered by Amos",
127
+ name: "amos-credit-card-payment-method-form",
128
+ height: getCreditCardFormInitialHeight(additionalFields),
129
+ });
130
+ host.appendChild(iframe);
131
+
132
+ const controller = attachPaymentMethodFormListeners(iframe, {
133
+ ...listenerOptions,
134
+ onHeightChange: (height) => {
135
+ iframe.style.height = height;
136
+ listenerOptions.onHeightChange?.(height);
137
+ },
138
+ onAppearanceReady: () => {
139
+ iframe.style.opacity = "1";
140
+ listenerOptions.onAppearanceReady?.();
141
+ },
142
+ });
143
+
144
+ return {
145
+ iframe,
146
+ update: controller.update,
147
+ destroy() {
148
+ controller.destroy();
149
+ iframe.remove();
150
+ },
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Options accepted by {@link mountAmosBankAccountPaymentMethodForm}.
156
+ */
157
+ export type AmosBankAccountPaymentMethodFormOptions =
158
+ PaymentMethodFormListenerOptions & {
159
+ /**
160
+ * The Amos render token for the bank-account payment method form.
161
+ *
162
+ * It is safe to pass this to the client. Create this on
163
+ * https://dashboard.amos.com.
164
+ */
165
+ renderToken: string;
166
+ };
167
+
168
+ /**
169
+ * Mount the secure bank-account payment method form into a container
170
+ * element. Returns a controller exposing the underlying iframe, an
171
+ * `update()` method, and a `destroy()` method.
172
+ *
173
+ * Use the returned `controller.iframe` when calling
174
+ * {@link validateForm}, {@link confirmPaymentIntent}, or
175
+ * {@link confirmSetupIntent}.
176
+ */
177
+ export function mountAmosBankAccountPaymentMethodForm(
178
+ container: Container,
179
+ options: AmosBankAccountPaymentMethodFormOptions,
180
+ ): AmosPaymentMethodFormMountController {
181
+ const host = resolveContainer(container);
182
+ const { renderToken, ...listenerOptions } = options;
183
+
184
+ const iframe = createIframe({
185
+ src: getBankAccountFormSrc(renderToken),
186
+ title: "Secure bank account payment method form powered by Amos",
187
+ name: "amos-bank-account-payment-method-form",
188
+ height: getBankAccountFormInitialHeight(),
189
+ });
190
+ host.appendChild(iframe);
191
+
192
+ const controller = attachPaymentMethodFormListeners(iframe, {
193
+ ...listenerOptions,
194
+ onHeightChange: (height) => {
195
+ iframe.style.height = height;
196
+ listenerOptions.onHeightChange?.(height);
197
+ },
198
+ onAppearanceReady: () => {
199
+ iframe.style.opacity = "1";
200
+ listenerOptions.onAppearanceReady?.();
201
+ },
202
+ });
203
+
204
+ return {
205
+ iframe,
206
+ update: controller.update,
207
+ destroy() {
208
+ controller.destroy();
209
+ iframe.remove();
210
+ },
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Options accepted by {@link mountAmosGooglePayButton}.
216
+ */
217
+ export type AmosGooglePayButtonOptions = GooglePayButtonListenerOptions & {
218
+ /**
219
+ * The Amos render token for the Google Pay button.
220
+ *
221
+ * It is safe to pass this to the client. Create this on
222
+ * https://dashboard.amos.com.
223
+ */
224
+ renderToken: string;
225
+ };
226
+
227
+ /**
228
+ * Controller returned by {@link mountAmosGooglePayButton}.
229
+ */
230
+ export type AmosGooglePayButtonMountController = GooglePayButtonController & {
231
+ /**
232
+ * The underlying `<iframe>` element.
233
+ */
234
+ iframe: HTMLIFrameElement;
235
+ };
236
+
237
+ /**
238
+ * Mount the secure Google Pay button (express checkout) into a
239
+ * container element. Returns a controller exposing the underlying
240
+ * iframe, an `update()` method, and a `destroy()` method.
241
+ */
242
+ export function mountAmosGooglePayButton(
243
+ container: Container,
244
+ options: AmosGooglePayButtonOptions,
245
+ ): AmosGooglePayButtonMountController {
246
+ const host = resolveContainer(container);
247
+ const { renderToken, ...listenerOptions } = options;
248
+
249
+ const iframe = createIframe({
250
+ src: getGooglePayButtonSrc(renderToken),
251
+ title: "Secure Google Pay button powered by Amos",
252
+ name: "amos-google-pay-button",
253
+ height: getGooglePayButtonInitialHeight(),
254
+ allow: "payment",
255
+ });
256
+ host.appendChild(iframe);
257
+
258
+ const controller = attachGooglePayButtonListeners(iframe, {
259
+ ...listenerOptions,
260
+ onHeightChange: (height) => {
261
+ iframe.style.height = height;
262
+ listenerOptions.onHeightChange?.(height);
263
+ },
264
+ onAppearanceReady: () => {
265
+ iframe.style.opacity = "1";
266
+ listenerOptions.onAppearanceReady?.();
267
+ },
268
+ });
269
+
270
+ return {
271
+ iframe,
272
+ update: controller.update,
273
+ destroy() {
274
+ controller.destroy();
275
+ iframe.remove();
276
+ },
277
+ };
278
+ }
@@ -0,0 +1,180 @@
1
+ import type { components } from "@amos.com/node";
2
+ import { getEmbedOrigin } from "./jwt";
3
+ import {
4
+ sendParentReadyMessage,
5
+ updateAppearance as sendUpdateAppearance,
6
+ } from "./messaging";
7
+ import type { Appearance, Message } from "./types";
8
+
9
+ /**
10
+ * The additional fields beyond the standard card number, expiration
11
+ * date, CVV, country, and postal code that are required to be filled
12
+ * out in the embedded credit-card form.
13
+ */
14
+ export type CreditCardAdditionalFields = {
15
+ cardholderName: boolean;
16
+ };
17
+
18
+ /**
19
+ * Build the iframe `src` URL for the embedded credit-card form.
20
+ */
21
+ export function getCreditCardFormSrc(
22
+ renderToken: string,
23
+ additionalFields: CreditCardAdditionalFields = { cardholderName: false },
24
+ ): string {
25
+ const enabled = Object.entries(additionalFields)
26
+ .filter(([, value]) => value)
27
+ .map(([key]) => key)
28
+ .join(",");
29
+
30
+ return `${getEmbedOrigin(renderToken)}/iframe/card?token=${renderToken}&additionalFields=${enabled}`;
31
+ }
32
+
33
+ /**
34
+ * Build the iframe `src` URL for the embedded bank-account form.
35
+ */
36
+ export function getBankAccountFormSrc(renderToken: string): string {
37
+ return `${getEmbedOrigin(renderToken)}/iframe/bank?token=${renderToken}`;
38
+ }
39
+
40
+ /**
41
+ * Default iframe pixel height for the credit-card form, taking the
42
+ * configured `additionalFields` into account.
43
+ */
44
+ export function getCreditCardFormInitialHeight(
45
+ additionalFields: CreditCardAdditionalFields = { cardholderName: false },
46
+ ): string {
47
+ return additionalFields.cardholderName ? "292px" : "212px";
48
+ }
49
+
50
+ /**
51
+ * Default iframe pixel height for the bank-account form.
52
+ */
53
+ export function getBankAccountFormInitialHeight(): string {
54
+ return "400px";
55
+ }
56
+
57
+ /**
58
+ * Options accepted by {@link attachPaymentMethodFormListeners}.
59
+ *
60
+ * Used by both the credit-card and bank-account forms, which share the
61
+ * same message protocol.
62
+ */
63
+ export type PaymentMethodFormListenerOptions = {
64
+ /**
65
+ * Custom appearance to apply when the iframe first becomes ready and
66
+ * whenever the appearance changes. Can be updated later via the
67
+ * returned controller's `update({ appearance })` method.
68
+ */
69
+ appearance?: Appearance;
70
+ /**
71
+ * Called whenever the iframe asks the host page to resize it. Update
72
+ * the iframe's `height` style here.
73
+ */
74
+ onHeightChange?: (height: string) => void;
75
+ /**
76
+ * Called once the iframe has applied the requested appearance and is
77
+ * ready to be revealed. A common implementation is to set the
78
+ * iframe's opacity from `0` to `1` to fade it in.
79
+ */
80
+ onAppearanceReady?: () => void;
81
+ /**
82
+ * Called when payment intent confirmation succeeds.
83
+ */
84
+ onPaymentIntentConfirmationSucceeded?: (
85
+ paymentIntent: components["schemas"]["PaymentIntent"],
86
+ ) => void;
87
+ /**
88
+ * Called when setup intent confirmation succeeds.
89
+ */
90
+ onSetupIntentConfirmationSucceeded?: (
91
+ setupIntent: components["schemas"]["SetupIntent"],
92
+ ) => void;
93
+ /**
94
+ * Called when payment or setup intent confirmation fails.
95
+ */
96
+ onConfirmationFailed: (errorMessage: string) => void;
97
+ };
98
+
99
+ /**
100
+ * Controller returned by {@link attachPaymentMethodFormListeners} and
101
+ * the credit-card / bank-account `mount*` helpers.
102
+ */
103
+ export type PaymentMethodFormController = {
104
+ /**
105
+ * Update one or more listener options without re-attaching the
106
+ * message listener. Pass `{ appearance }` to push new appearance
107
+ * overrides into the iframe.
108
+ */
109
+ update: (patch: Partial<PaymentMethodFormListenerOptions>) => void;
110
+ /**
111
+ * Detach the iframe message listener.
112
+ */
113
+ destroy: () => void;
114
+ };
115
+
116
+ /**
117
+ * Wire up the host-page side of the credit-card or bank-account iframe
118
+ * message protocol on an existing `<iframe>` element. Returns a
119
+ * controller for updating options and tearing down the listener.
120
+ *
121
+ * The iframe is expected to have already been added to the DOM with the
122
+ * correct `src` (see {@link getCreditCardFormSrc} /
123
+ * {@link getBankAccountFormSrc}).
124
+ */
125
+ export function attachPaymentMethodFormListeners(
126
+ iframe: HTMLIFrameElement,
127
+ options: PaymentMethodFormListenerOptions,
128
+ ): PaymentMethodFormController {
129
+ let current = { ...options };
130
+
131
+ function handleMessage(event: MessageEvent<Message>) {
132
+ switch (event.data.type) {
133
+ case "IFRAME_READY":
134
+ sendParentReadyMessage(iframe);
135
+ sendUpdateAppearance({ iframe, appearance: current.appearance });
136
+ break;
137
+
138
+ case "UPDATE_HEIGHT":
139
+ current.onHeightChange?.(event.data.height);
140
+ break;
141
+
142
+ case "UPDATE_APPEARANCE":
143
+ sendUpdateAppearance({ iframe, appearance: event.data.appearance });
144
+ break;
145
+
146
+ case "UPDATED_APPEARANCE":
147
+ current.onAppearanceReady?.();
148
+ break;
149
+
150
+ case "PAYMENT_INTENT_CONFIRMATION_SUCCEEDED":
151
+ current.onPaymentIntentConfirmationSucceeded?.(
152
+ event.data.paymentIntent,
153
+ );
154
+ break;
155
+
156
+ case "SETUP_INTENT_CONFIRMATION_SUCCEEDED":
157
+ current.onSetupIntentConfirmationSucceeded?.(event.data.setupIntent);
158
+ break;
159
+
160
+ case "CONFIRMATION_FAILED":
161
+ current.onConfirmationFailed(event.data.errorMessage);
162
+ break;
163
+ }
164
+ }
165
+
166
+ window.addEventListener("message", handleMessage);
167
+
168
+ return {
169
+ update(patch) {
170
+ const hadAppearance = "appearance" in patch;
171
+ current = { ...current, ...patch };
172
+ if (hadAppearance) {
173
+ sendUpdateAppearance({ iframe, appearance: current.appearance });
174
+ }
175
+ },
176
+ destroy() {
177
+ window.removeEventListener("message", handleMessage);
178
+ },
179
+ };
180
+ }