@artaio/arta-browser 2.21.0 → 2.23.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.
package/dist/pay.js ADDED
@@ -0,0 +1,336 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var messaging = require('./messaging.js');
6
+ var payConfig = require('./payConfig.js');
7
+
8
+ // Arta Pay checkout modal.
9
+ //
10
+ // Lifecycle: the constructor mounts the overlay + iframe immediately but
11
+ // hidden, so the widget can handshake and validate the purchase request
12
+ // context (`arta-pay:ready` -> `onReady`) before the seller enables its
13
+ // button. `open()` reveals the overlay and tells the widget it is visible;
14
+ // `close()` hides it without tearing anything down, so the widget keeps its
15
+ // session and resumes where it was on the next `open()`. `destroy()` removes
16
+ // the frame for good.
17
+ //
18
+ // Every message from the frame is accepted only when both the origin and the
19
+ // source window match the iframe the SDK created; every message to the frame
20
+ // is posted with the pinned pay origin.
21
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
22
+ const OVERLAY_ID = 'arta-pay-overlay';
23
+ const FRAME_MIN_HEIGHT_PX = 240;
24
+ const RESIZE_DEAD_BAND_PX = 2;
25
+ // Hidden means invisible and inert, not `display:none`: the iframe has to
26
+ // keep loading and laying out so the widget can validate the context and
27
+ // measure itself before the seller opens the modal.
28
+ const overlayBaseCss = 'box-sizing:border-box;position:fixed;inset:0;display:flex;background:rgba(17,15,16,0.55);' +
29
+ 'z-index:2147483000;visibility:hidden;pointer-events:none;';
30
+ const overlayPositionCss = {
31
+ center: 'align-items:center;justify-content:center;',
32
+ full_screen: 'align-items:stretch;justify-content:stretch;',
33
+ left: 'align-items:stretch;justify-content:flex-start;',
34
+ right: 'align-items:stretch;justify-content:flex-end;',
35
+ };
36
+ // The widget reports its content height via arta-pay:resize; the centered
37
+ // card follows it (capped to the viewport) while the side panels and full
38
+ // screen take the full viewport height.
39
+ // The centered height opens on the sign-in screen's own height, which the
40
+ // widget holds from its first paint (.arta-pay-shell min-height); starting
41
+ // anywhere else makes the frame resize once the widget reports itself.
42
+ // content-box is pinned because seller pages commonly reset `* { box-sizing:
43
+ // border-box }`, which would let the frame's border eat into the viewport the
44
+ // widget measures itself against.
45
+ const frameBaseCss = 'box-sizing:content-box;border:0;background:#fff;transition:height 0.15s ease;';
46
+ const framePositionCss = {
47
+ center: 'width:min(576px, calc(100vw - 32px));height:min(539px, calc(100vh - 32px));' +
48
+ 'border:1px solid #d2d2d2;border-radius:8px;' +
49
+ 'box-shadow:0 24px 64px rgba(17,15,16,0.35);',
50
+ full_screen: 'width:100vw;height:100vh;',
51
+ left: 'width:min(460px, 100vw);height:100vh;border-right:1px solid #d2d2d2;' +
52
+ 'box-shadow:24px 0 64px rgba(17,15,16,0.35);',
53
+ right: 'width:min(460px, 100vw);height:100vh;border-left:1px solid #d2d2d2;' +
54
+ 'box-shadow:-24px 0 64px rgba(17,15,16,0.35);',
55
+ };
56
+ class Pay {
57
+ input;
58
+ callbacks;
59
+ config;
60
+ isReady = false;
61
+ isOpen = false;
62
+ overlay;
63
+ iframe;
64
+ handshakeTimer;
65
+ destroyed = false;
66
+ completed = false;
67
+ openWhenMounted = false;
68
+ lastFrameHeight;
69
+ payOrigin;
70
+ messageListener = (event) => this.handleMessage(event);
71
+ keydownListener = (event) => {
72
+ if (event.key === 'Escape' && this.isOpen) {
73
+ this.requestClose();
74
+ }
75
+ };
76
+ domReadyListener = () => this.mount();
77
+ constructor(input, callbacks, config) {
78
+ this.input = input;
79
+ this.callbacks = callbacks;
80
+ this.config = config;
81
+ this.payOrigin = config.payOrigin ?? payConfig.DEFAULT_PAY_ORIGIN;
82
+ if (document.body) {
83
+ this.mount();
84
+ }
85
+ else {
86
+ // Constructed from a <head> script: wait for the body to exist.
87
+ document.addEventListener('DOMContentLoaded', this.domReadyListener);
88
+ }
89
+ }
90
+ open() {
91
+ if (this.destroyed) {
92
+ throw new Error('This Arta Pay instance has been destroyed');
93
+ }
94
+ if (this.isOpen) {
95
+ return;
96
+ }
97
+ if (!this.overlay) {
98
+ this.openWhenMounted = true;
99
+ return;
100
+ }
101
+ this.reveal();
102
+ }
103
+ // Hides the overlay without tearing down the iframe, so the widget's
104
+ // session state survives close/reopen.
105
+ close() {
106
+ this.openWhenMounted = false;
107
+ if (this.overlay) {
108
+ this.overlay.style.visibility = 'hidden';
109
+ this.overlay.style.pointerEvents = 'none';
110
+ }
111
+ document.removeEventListener('keydown', this.keydownListener);
112
+ this.isOpen = false;
113
+ }
114
+ destroy() {
115
+ this.close();
116
+ document.removeEventListener('DOMContentLoaded', this.domReadyListener);
117
+ window.removeEventListener('message', this.messageListener);
118
+ this.clearHandshakeTimer();
119
+ this.overlay?.remove();
120
+ this.overlay = undefined;
121
+ this.iframe = undefined;
122
+ this.isReady = false;
123
+ this.destroyed = true;
124
+ }
125
+ mount() {
126
+ document.removeEventListener('DOMContentLoaded', this.domReadyListener);
127
+ if (this.destroyed || this.overlay) {
128
+ return;
129
+ }
130
+ window.addEventListener('message', this.messageListener);
131
+ const position = this.config.position;
132
+ this.overlay = document.createElement('div');
133
+ this.overlay.id = OVERLAY_ID;
134
+ this.overlay.style.cssText = overlayBaseCss + overlayPositionCss[position];
135
+ this.overlay.addEventListener('click', (event) => {
136
+ if (event.target === this.overlay) {
137
+ this.requestClose();
138
+ }
139
+ });
140
+ this.iframe = document.createElement('iframe');
141
+ this.iframe.style.cssText = frameBaseCss + framePositionCss[position];
142
+ this.iframe.setAttribute('title', 'Arta Pay');
143
+ // The public key is an identifier, not a credential — it rides the
144
+ // document request so the server can validate it (and, later, derive
145
+ // per-key restrictions such as allowed embedding domains) before
146
+ // serving the widget. The client token never goes in a URL.
147
+ this.iframe.src =
148
+ this.payOrigin +
149
+ '/artapay-widget/embed?prid=' +
150
+ encodeURIComponent(this.input.purchaseRequestId) +
151
+ '&pk=' +
152
+ encodeURIComponent(this.config.apiKey);
153
+ this.overlay.appendChild(this.iframe);
154
+ document.body.appendChild(this.overlay);
155
+ this.startHandshakeTimer();
156
+ if (this.openWhenMounted) {
157
+ this.openWhenMounted = false;
158
+ this.reveal();
159
+ }
160
+ }
161
+ reveal() {
162
+ if (!this.overlay) {
163
+ return;
164
+ }
165
+ this.overlay.style.visibility = 'visible';
166
+ this.overlay.style.pointerEvents = 'auto';
167
+ document.addEventListener('keydown', this.keydownListener);
168
+ this.isOpen = true;
169
+ this.postToFrame({ type: 'arta-pay:open' });
170
+ }
171
+ // The widget owns the close decision; the SDK only requests it. Before the
172
+ // handshake completes there is nobody to ask, so close directly.
173
+ requestClose() {
174
+ if (this.isReady) {
175
+ this.postToFrame({ type: 'arta-pay:close-request' });
176
+ }
177
+ else {
178
+ this.close();
179
+ this.callbacks.onClose?.({
180
+ purchaseRequestId: this.input.purchaseRequestId,
181
+ reason: 'customer',
182
+ });
183
+ }
184
+ }
185
+ handleMessage(event) {
186
+ if (event.origin !== this.payOrigin ||
187
+ !this.iframe ||
188
+ event.source !== this.iframe.contentWindow ||
189
+ !messaging.isPayInboundMessage(event.data)) {
190
+ return;
191
+ }
192
+ const data = event.data;
193
+ switch (data.type) {
194
+ case 'arta-pay:handshake':
195
+ this.handleHandshake();
196
+ break;
197
+ case 'arta-pay:ready':
198
+ this.handleReady();
199
+ break;
200
+ case 'arta-pay:resize':
201
+ this.handleResize(data);
202
+ break;
203
+ case 'arta-pay:complete':
204
+ this.handleComplete(data);
205
+ break;
206
+ case 'arta-pay:close':
207
+ this.close();
208
+ this.callbacks.onClose?.({
209
+ purchaseRequestId: this.input.purchaseRequestId,
210
+ reason: messaging.parseCloseReason(data),
211
+ });
212
+ break;
213
+ case 'arta-pay:error':
214
+ this.handleError(data);
215
+ break;
216
+ }
217
+ }
218
+ handleHandshake() {
219
+ // A handshake after ready means the frame reloaded: it has to validate
220
+ // the context again before the modal can be considered ready.
221
+ this.isReady = false;
222
+ this.startHandshakeTimer();
223
+ this.postToFrame({
224
+ type: 'arta-pay:init',
225
+ purchaseRequestId: this.input.purchaseRequestId,
226
+ clientToken: this.input.clientToken,
227
+ position: this.config.position,
228
+ });
229
+ }
230
+ handleReady() {
231
+ this.clearHandshakeTimer();
232
+ if (this.isReady) {
233
+ return;
234
+ }
235
+ this.isReady = true;
236
+ this.callbacks.onReady?.();
237
+ }
238
+ handleResize(data) {
239
+ // The side panels and full screen keep the full viewport height.
240
+ if (this.config.position !== 'center' || !this.iframe) {
241
+ return;
242
+ }
243
+ const height = messaging.parseResizeHeight(data);
244
+ if (height === undefined) {
245
+ return;
246
+ }
247
+ const px = Math.max(FRAME_MIN_HEIGHT_PX, Math.round(height));
248
+ // Sub-pixel layouts (zoom, display scaling) make a re-measured frame come
249
+ // back a pixel short; a dead band keeps that from ratcheting the height.
250
+ if (this.lastFrameHeight !== undefined &&
251
+ Math.abs(px - this.lastFrameHeight) < RESIZE_DEAD_BAND_PX) {
252
+ return;
253
+ }
254
+ this.lastFrameHeight = px;
255
+ this.iframe.style.height = `min(${px}px, calc(100vh - 32px))`;
256
+ }
257
+ // `onComplete` fires at most once per instance. The widget may re-render
258
+ // its outcome screen (and re-send `complete`) when the buyer reopens a
259
+ // finished checkout; the seller only hears about it the first time.
260
+ handleComplete(data) {
261
+ if (this.completed) {
262
+ return;
263
+ }
264
+ const completion = messaging.parseCompletion(data);
265
+ if (!completion ||
266
+ (completion.purchaseRequestId !== undefined &&
267
+ completion.purchaseRequestId !== this.input.purchaseRequestId)) {
268
+ // Not a v2 outcome (or an outcome for another purchase request). The
269
+ // buyer may well have finished, so the modal stays open; the seller
270
+ // must confirm server-side either way.
271
+ this.callbacks.onError?.({
272
+ code: 'protocol_error',
273
+ message: 'The Arta Pay widget reported an outcome this SDK does not understand; ' +
274
+ 'confirm the purchase request status server-side',
275
+ recoverable: true,
276
+ });
277
+ return;
278
+ }
279
+ this.completed = true;
280
+ const result = {
281
+ purchaseRequestId: this.input.purchaseRequestId,
282
+ status: completion.status,
283
+ };
284
+ if (completion.purchaseId !== undefined) {
285
+ result.purchaseId = completion.purchaseId;
286
+ }
287
+ this.callbacks.onComplete?.(result);
288
+ }
289
+ handleError(data) {
290
+ const error = messaging.parseError(data);
291
+ this.callbacks.onError?.(error);
292
+ if (!error.recoverable) {
293
+ this.abort();
294
+ }
295
+ }
296
+ handleHandshakeTimeout() {
297
+ this.handshakeTimer = undefined;
298
+ this.callbacks.onError?.({
299
+ code: 'frame_load_failed',
300
+ message: 'The Arta Pay widget did not respond in time',
301
+ recoverable: true,
302
+ });
303
+ this.abort();
304
+ }
305
+ // The widget cannot (currently) complete this purchase request. The modal
306
+ // goes away if it was showing, but the iframe stays mounted so a later
307
+ // `open()` shows the widget's own error screen instead of a blank frame.
308
+ abort() {
309
+ this.clearHandshakeTimer();
310
+ this.isReady = false;
311
+ if (this.isOpen) {
312
+ this.close();
313
+ this.callbacks.onClose?.({
314
+ purchaseRequestId: this.input.purchaseRequestId,
315
+ reason: 'error',
316
+ });
317
+ }
318
+ }
319
+ postToFrame(message) {
320
+ if (this.iframe?.contentWindow) {
321
+ messaging.postPayMessage(this.iframe.contentWindow, this.payOrigin, message);
322
+ }
323
+ }
324
+ startHandshakeTimer() {
325
+ this.clearHandshakeTimer();
326
+ this.handshakeTimer = window.setTimeout(() => this.handleHandshakeTimeout(), HANDSHAKE_TIMEOUT_MS);
327
+ }
328
+ clearHandshakeTimer() {
329
+ if (this.handshakeTimer !== undefined) {
330
+ window.clearTimeout(this.handshakeTimer);
331
+ this.handshakeTimer = undefined;
332
+ }
333
+ }
334
+ }
335
+
336
+ exports.default = Pay;
@@ -0,0 +1,39 @@
1
+ import { type ArtaJsFullConfig } from './arta';
2
+ export interface PayInput {
3
+ purchaseRequestId: string;
4
+ clientToken: string;
5
+ }
6
+ export declare const PAY_COMPLETION_STATUSES: readonly ["confirmed", "processing", "declined"];
7
+ export type PayCompletionStatus = typeof PAY_COMPLETION_STATUSES[number];
8
+ export interface PayCompletion {
9
+ purchaseRequestId: string;
10
+ purchaseId?: string;
11
+ status: PayCompletionStatus;
12
+ }
13
+ export declare const PAY_CLOSE_REASONS: readonly ["customer", "complete", "error"];
14
+ export type PayCloseReason = typeof PAY_CLOSE_REASONS[number];
15
+ export interface PayCloseEvent {
16
+ purchaseRequestId: string;
17
+ reason: PayCloseReason;
18
+ }
19
+ export interface PayError {
20
+ code: string;
21
+ message: string;
22
+ recoverable: boolean;
23
+ requestId?: string;
24
+ }
25
+ export interface PayCallbacks {
26
+ onReady?: () => void;
27
+ onComplete?: (result: PayCompletion) => void;
28
+ onClose?: (event: PayCloseEvent) => void;
29
+ onError?: (error: PayError) => void;
30
+ }
31
+ export type PayPosition = 'center' | 'full_screen' | 'left' | 'right';
32
+ export interface PayConfig {
33
+ position: PayPosition;
34
+ }
35
+ export type PartialPayConfig = Partial<PayConfig>;
36
+ export interface PayFullConfig extends PayConfig, ArtaJsFullConfig {
37
+ }
38
+ export declare const getFullPayConfig: (artaConfig: ArtaJsFullConfig, payConfig?: PartialPayConfig) => PayFullConfig;
39
+ export declare const DEFAULT_PAY_ORIGIN = "https://collectors.arta.io";
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ // Outcome vocabulary shared with the widget (protocol v2) and the seller
4
+ // integration reference. `confirmed` = deposit charged, purchase active;
5
+ // `processing` = deposit charge still settling (ACH); `declined` is reserved
6
+ // and not emitted by the widget yet.
7
+ const PAY_COMPLETION_STATUSES = [
8
+ 'confirmed',
9
+ 'processing',
10
+ 'declined',
11
+ ];
12
+ const PAY_CLOSE_REASONS = ['customer', 'complete', 'error'];
13
+ const defaultPayConfig = {
14
+ position: 'center',
15
+ };
16
+ const getFullPayConfig = (artaConfig, payConfig = {}) => {
17
+ return Object.assign({}, defaultPayConfig, artaConfig, payConfig);
18
+ };
19
+ const DEFAULT_PAY_ORIGIN = 'https://collectors.arta.io';
20
+
21
+ exports.DEFAULT_PAY_ORIGIN = DEFAULT_PAY_ORIGIN;
22
+ exports.PAY_CLOSE_REASONS = PAY_CLOSE_REASONS;
23
+ exports.PAY_COMPLETION_STATUSES = PAY_COMPLETION_STATUSES;
24
+ exports.getFullPayConfig = getFullPayConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artaio/arta-browser",
3
- "version": "2.21.0",
3
+ "version": "2.23.0",
4
4
  "description": "",
5
5
  "source": "lib/index.ts",
6
6
  "main": "./dist/index.js",
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "scripts": {
14
14
  "test": "npm run lint",
15
+ "typecheck": "tsc --noEmit",
15
16
  "build": "rollup -c && copyfiles -u 1 \"./lib/**/*.css\" \"./dist/\"",
16
17
  "lint": "eslint .",
17
18
  "lint:fix": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|tsx|jsx|json)\"",
@@ -26,6 +27,7 @@
26
27
  "@rollup/plugin-terser": "^1.0.0",
27
28
  "@rollup/plugin-typescript": "^12.3.0",
28
29
  "@semantic-release/changelog": "^6.0.0",
30
+ "@semantic-release/exec": "^7.1.0",
29
31
  "@semantic-release/git": "^10.0.0",
30
32
  "@typescript-eslint/eslint-plugin": "^5.39.0",
31
33
  "@typescript-eslint/parser": "^5.40.1",