bison-web-components 1.0.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,536 @@
1
+ /**
2
+ * WioBankAccount Web Component
3
+ *
4
+ * A simple web component that provides a button to directly trigger the Plaid flow
5
+ * and calls the getAccountByEmail API when an email prop is provided.
6
+ *
7
+ * @author @kfajardo
8
+ * @version 1.0.0
9
+ *
10
+ * @requires BisonJibPayAPI - Must be loaded before this component (from component.js)
11
+ *
12
+ * @example
13
+ * ```html
14
+ * <script src="component.js"></script>
15
+ * <script src="wio-bank-account.js"></script>
16
+ *
17
+ * <wio-bank-account id="linking" email="user@example.com" button-text="Link Bank Account"></wio-bank-account>
18
+ * <script>
19
+ * const linking = document.getElementById('linking');
20
+ * linking.addEventListener('plaid-link-success', (e) => {
21
+ * console.log('Success:', e.detail);
22
+ * });
23
+ * </script>
24
+ * ```
25
+ */
26
+
27
+ class WioBankAccount extends HTMLElement {
28
+ constructor() {
29
+ super();
30
+ this.attachShadow({ mode: "open" });
31
+
32
+ // Consumer-assignable callback
33
+ this._onPlaidSuccess = null;
34
+
35
+ // API Configuration
36
+ this.apiBaseURL =
37
+ this.getAttribute("api-base-url") ||
38
+ "https://bison-jib-development.azurewebsites.net";
39
+ this.embeddableKey =
40
+ this.getAttribute("embeddable-key") ||
41
+ "R80WMkbNN8457RofiMYx03DL65P06IaVT30Q2emYJUBQwYCzRC";
42
+
43
+ // Check if BisonJibPayAPI is available
44
+ if (typeof BisonJibPayAPI === "undefined") {
45
+ console.error(
46
+ "WioBankAccount: BisonJibPayAPI is not available. Please ensure component.js is loaded before wio-bank-account.js"
47
+ );
48
+ this.api = null;
49
+ } else {
50
+ this.api = new BisonJibPayAPI(this.apiBaseURL, this.embeddableKey);
51
+ }
52
+
53
+ // Internal state
54
+ this._state = {
55
+ email: null,
56
+ buttonText: this.getAttribute("button-text") || "Link Bank Account",
57
+ isLoading: false,
58
+ accountData: null,
59
+ moovAccountId: null,
60
+ error: null,
61
+ plaidLoaded: false,
62
+ plaidLinkToken: null,
63
+ initializationError: false,
64
+ isRefetchingPaymentMethods: false,
65
+ };
66
+
67
+ // Render the component
68
+ this.render();
69
+ }
70
+
71
+ // ==================== STATIC PROPERTIES ====================
72
+
73
+ static get observedAttributes() {
74
+ return ["email", "api-base-url", "embeddable-key", "button-text"];
75
+ }
76
+
77
+ // ==================== PROPERTY GETTERS/SETTERS ====================
78
+
79
+ get email() {
80
+ return this._state.email;
81
+ }
82
+
83
+ get moovAccountId() {
84
+ return this._state.moovAccountId;
85
+ }
86
+
87
+ get buttonText() {
88
+ return this._state.buttonText;
89
+ }
90
+
91
+ get onPlaidSuccess() {
92
+ return this._onPlaidSuccess;
93
+ }
94
+
95
+ set onPlaidSuccess(fn) {
96
+ if (fn !== null && typeof fn !== "function") {
97
+ console.warn("WioBankAccount: onPlaidSuccess must be a function or null");
98
+ return;
99
+ }
100
+ this._onPlaidSuccess = fn;
101
+ }
102
+
103
+ set email(value) {
104
+ console.log("WioBankAccount: Setting email to:", value);
105
+ const oldEmail = this._state.email;
106
+ this._state.email = value;
107
+
108
+ const currentAttr = this.getAttribute("email");
109
+ if (currentAttr !== value) {
110
+ if (value) {
111
+ this.setAttribute("email", value);
112
+ } else {
113
+ this.removeAttribute("email");
114
+ }
115
+ }
116
+
117
+ if (value && value !== oldEmail && this.isConnected) {
118
+ this.initializeAccount();
119
+ }
120
+ }
121
+
122
+ set buttonText(value) {
123
+ const nextValue = value == null ? "" : String(value);
124
+ const oldValue = this._state.buttonText;
125
+
126
+ this._state.buttonText = nextValue || "Link Bank Account";
127
+
128
+ const currentAttr = this.getAttribute("button-text");
129
+ if (currentAttr !== nextValue) {
130
+ if (nextValue) {
131
+ this.setAttribute("button-text", nextValue);
132
+ } else {
133
+ this.removeAttribute("button-text");
134
+ }
135
+ }
136
+
137
+ if (oldValue !== this._state.buttonText) {
138
+ this.updateButtonLabel();
139
+ }
140
+ }
141
+
142
+ // ==================== LIFECYCLE METHODS ====================
143
+
144
+ connectedCallback() {
145
+ const emailAttr = this.getAttribute("email");
146
+ if (emailAttr && !this._state.email) {
147
+ this._state.email = emailAttr;
148
+ }
149
+
150
+ this.ensurePlaidSDK();
151
+ this.setupEventListeners();
152
+
153
+ if (this._state.email) {
154
+ this.initializeAccount();
155
+ }
156
+ }
157
+
158
+ disconnectedCallback() {
159
+ this.removeEventListeners();
160
+ }
161
+
162
+ attributeChangedCallback(name, oldValue, newValue) {
163
+ if (oldValue === newValue) return;
164
+
165
+ switch (name) {
166
+ case "email":
167
+ this._state.email = newValue;
168
+ if (newValue && this.isConnected) {
169
+ this.initializeAccount();
170
+ }
171
+ break;
172
+
173
+ case "api-base-url":
174
+ this.apiBaseURL = newValue;
175
+ if (this.api) {
176
+ this.api = new BisonJibPayAPI(this.apiBaseURL, this.embeddableKey);
177
+ }
178
+ if (this._state.email && this.isConnected) {
179
+ this.initializeAccount();
180
+ }
181
+ break;
182
+
183
+ case "embeddable-key":
184
+ this.embeddableKey = newValue;
185
+ if (this.api) {
186
+ this.api = new BisonJibPayAPI(this.apiBaseURL, this.embeddableKey);
187
+ }
188
+ if (this._state.email && this.isConnected) {
189
+ this.initializeAccount();
190
+ }
191
+ break;
192
+
193
+ case "button-text":
194
+ this._state.buttonText = newValue || "Link Bank Account";
195
+ this.updateButtonLabel();
196
+ break;
197
+ }
198
+ }
199
+
200
+ // ==================== PLAID SDK LOADING ====================
201
+
202
+ async ensurePlaidSDK() {
203
+ if (window.Plaid) {
204
+ this._state.plaidLoaded = true;
205
+ return Promise.resolve();
206
+ }
207
+
208
+ const existingScript = document.querySelector(
209
+ 'script[src*="plaid.com/link"]'
210
+ );
211
+ if (existingScript) {
212
+ return new Promise((resolve, reject) => {
213
+ existingScript.addEventListener("load", () => {
214
+ this._state.plaidLoaded = true;
215
+ resolve();
216
+ });
217
+ existingScript.addEventListener("error", () =>
218
+ reject(new Error("Failed to load Plaid SDK"))
219
+ );
220
+ });
221
+ }
222
+
223
+ return new Promise((resolve, reject) => {
224
+ const script = document.createElement("script");
225
+ script.src = "https://cdn.plaid.com/link/v2/stable/link-initialize.js";
226
+ script.async = true;
227
+ script.defer = true;
228
+
229
+ script.onload = () => {
230
+ this._state.plaidLoaded = true;
231
+ resolve();
232
+ };
233
+
234
+ script.onerror = () => {
235
+ const error = new Error("Failed to load Plaid SDK from CDN");
236
+ this._state.error = error.message;
237
+ reject(error);
238
+ };
239
+
240
+ document.head.appendChild(script);
241
+ });
242
+ }
243
+
244
+ // ==================== EVENT HANDLING ====================
245
+
246
+ setupEventListeners() {
247
+ const button = this.shadowRoot.querySelector(".link-payment-btn");
248
+ if (button) {
249
+ button.addEventListener("click", this.openPlaidLink.bind(this));
250
+ }
251
+ }
252
+
253
+ removeEventListeners() {
254
+ // No specific global listeners to remove
255
+ }
256
+
257
+ // ==================== INITIALIZATION METHODS ====================
258
+
259
+ async initializeAccount() {
260
+ if (!this._state.email) {
261
+ console.warn("WioBankAccount: Email is required for initialization");
262
+ return;
263
+ }
264
+
265
+ if (!this.api) {
266
+ console.error(
267
+ "WioBankAccount: BisonJibPayAPI is not available."
268
+ );
269
+ this._state.initializationError = true;
270
+ this.updateMainButtonState();
271
+ return;
272
+ }
273
+
274
+ try {
275
+ this._state.isLoading = true;
276
+ this._state.error = null;
277
+ this._state.initializationError = false;
278
+
279
+ this.updateMainButtonState();
280
+
281
+ const result = await this.api.getAccountByEmail(this._state.email);
282
+ this._state.accountData = result.data;
283
+ this._state.moovAccountId = result.data.moovAccountId || null;
284
+
285
+ this.updateMainButtonState();
286
+
287
+ await this.initializePlaidToken();
288
+ } catch (error) {
289
+ this._state.isLoading = false;
290
+ this._state.error = error.message || "Failed to fetch account data";
291
+ this._state.initializationError = true;
292
+ console.error("WioBankAccount: Account initialization failed", error);
293
+ this.updateMainButtonState();
294
+ }
295
+ }
296
+
297
+ async initializePlaidToken() {
298
+ try {
299
+ const plaidLinkResult = await this.api.generatePlaidToken(
300
+ this._state.email
301
+ );
302
+
303
+ if (!plaidLinkResult.success) {
304
+ throw new Error(
305
+ plaidLinkResult.message ||
306
+ "Error occurred while generating Plaid Link token"
307
+ );
308
+ }
309
+
310
+ this._state.plaidLinkToken = plaidLinkResult.data.linkToken;
311
+ this._state.isLoading = false;
312
+ this.updateMainButtonState();
313
+ } catch (error) {
314
+ this._state.isLoading = false;
315
+ this._state.error = error.message || "Failed to generate Plaid token";
316
+ this._state.initializationError = true;
317
+ this.updateMainButtonState();
318
+ }
319
+ }
320
+
321
+ async openPlaidLink() {
322
+ if (!this._state.plaidLoaded) {
323
+ try {
324
+ await this.ensurePlaidSDK();
325
+ } catch (error) {
326
+ console.error("WioBankAccount: Failed to load Plaid SDK:", error);
327
+ return;
328
+ }
329
+ }
330
+
331
+ if (!this._state.plaidLinkToken) {
332
+ console.error("WioBankAccount: Plaid Link token not available");
333
+ return;
334
+ }
335
+
336
+ const handler = window.Plaid.create({
337
+ token: this._state.plaidLinkToken,
338
+ onSuccess: async (public_token, metadata) => {
339
+ // Invoke consumer-assigned callback immediately on Plaid success
340
+ if (typeof this._onPlaidSuccess === "function") {
341
+ try {
342
+ this._onPlaidSuccess({ public_token, metadata });
343
+ } catch (cbError) {
344
+ console.error("WioBankAccount: onPlaidSuccess callback error", cbError);
345
+ }
346
+ }
347
+
348
+ const moovAccountId = this._state.moovAccountId;
349
+
350
+ if (!moovAccountId) {
351
+ console.error("WioBankAccount: Moov Account ID not found");
352
+ return;
353
+ }
354
+
355
+ // Show spinner on button while adding account
356
+ this._state.isLoading = true;
357
+ this.updateMainButtonState();
358
+
359
+ requestAnimationFrame(async () => {
360
+ try {
361
+ console.log("WioBankAccount: Adding Plaid account to Moov...");
362
+
363
+ const result = await this.api.addPlaidAccountToMoov(
364
+ public_token,
365
+ metadata.account_id,
366
+ moovAccountId
367
+ );
368
+
369
+ console.log("WioBankAccount: Plaid Link success", result);
370
+
371
+ this._state.isLoading = false;
372
+ this.updateMainButtonState();
373
+
374
+ const successDetail = { public_token, metadata, result };
375
+
376
+ this.dispatchEvent(
377
+ new CustomEvent("plaid-link-success", {
378
+ detail: successDetail,
379
+ bubbles: true,
380
+ composed: true,
381
+ })
382
+ );
383
+ } catch (error) {
384
+ console.error(
385
+ "WioBankAccount: Failed to add Plaid account to Moov",
386
+ error
387
+ );
388
+
389
+ this._state.isLoading = false;
390
+ this.updateMainButtonState();
391
+
392
+ this.dispatchEvent(
393
+ new CustomEvent("plaid-link-error", {
394
+ detail: { error: error.message, metadata },
395
+ bubbles: true,
396
+ composed: true,
397
+ })
398
+ );
399
+ }
400
+ });
401
+ },
402
+ onExit: (err, metadata) => {
403
+ console.log("WioBankAccount: Plaid Link exit", err, metadata);
404
+ if (err) {
405
+ this.dispatchEvent(
406
+ new CustomEvent("plaid-link-error", {
407
+ detail: { error: err, metadata },
408
+ bubbles: true,
409
+ composed: true,
410
+ })
411
+ );
412
+ }
413
+ },
414
+ });
415
+
416
+ handler.open();
417
+ }
418
+
419
+ updateMainButtonState() {
420
+ const button = this.shadowRoot.querySelector(".link-payment-btn");
421
+ if (!button) return;
422
+
423
+ if (this._state.isLoading) {
424
+ button.classList.add("loading");
425
+ button.classList.remove("error");
426
+ button.disabled = true;
427
+ } else if (this._state.initializationError) {
428
+ button.classList.remove("loading");
429
+ button.classList.add("error");
430
+ button.disabled = true;
431
+ } else {
432
+ button.classList.remove("loading");
433
+ button.classList.remove("error");
434
+ button.disabled = false;
435
+ }
436
+ }
437
+
438
+ updateButtonLabel() {
439
+ const label = this.shadowRoot.querySelector(".link-payment-label");
440
+ if (label) {
441
+ label.textContent = this._state.buttonText || "Link Bank Account";
442
+ }
443
+ }
444
+
445
+ render() {
446
+ this.shadowRoot.innerHTML = `
447
+ <style>
448
+ :host {
449
+ display: inline-block;
450
+ font-family: var(--font-sans, 'Inter', system-ui, sans-serif);
451
+ color: var(--color-secondary, #5f6e78);
452
+ }
453
+
454
+ .link-payment-btn {
455
+ padding: 12px 24px;
456
+ background: var(--color-primary, #4c7b63);
457
+ color: var(--color-white, #fff);
458
+ border: none;
459
+ border-radius: var(--radius-xl, 0.75rem);
460
+ font-size: var(--text-sm, 0.875rem);
461
+ font-weight: var(--font-weight-medium, 500);
462
+ cursor: pointer;
463
+ transition: all var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
464
+ display: inline-flex;
465
+ align-items: center;
466
+ gap: 8px;
467
+ height: 40px;
468
+ box-sizing: border-box;
469
+ }
470
+
471
+ .link-payment-btn:hover:not(.error):not(.loading) {
472
+ background: var(--color-primary-hover, #436c57);
473
+ }
474
+
475
+ .link-payment-btn:active:not(.error):not(.loading) {
476
+ background: var(--color-primary-active, #3d624f);
477
+ transform: translateY(0);
478
+ }
479
+
480
+ .link-payment-btn.error {
481
+ background: var(--color-gray-400, #9ca3af);
482
+ cursor: not-allowed;
483
+ }
484
+
485
+ .link-payment-btn.loading {
486
+ background: var(--color-primary-soft, #678f7a);
487
+ cursor: wait;
488
+ }
489
+
490
+ .link-payment-btn .loading-spinner {
491
+ display: none;
492
+ width: 16px;
493
+ height: 16px;
494
+ border: 2px solid rgba(255, 255, 255, 0.3);
495
+ border-top-color: var(--color-white, #fff);
496
+ border-radius: 50%;
497
+ animation: spin 0.8s linear infinite;
498
+ box-sizing: border-box;
499
+ }
500
+
501
+ .link-payment-btn.loading .loading-spinner {
502
+ display: inline-block;
503
+ }
504
+
505
+ @keyframes spin {
506
+ to {
507
+ transform: rotate(360deg);
508
+ }
509
+ }
510
+ </style>
511
+
512
+ <button class="link-payment-btn" id="mainButton">
513
+ <span class="loading-spinner"></span>
514
+ <span class="link-payment-label">${this._state.buttonText}</span>
515
+ <svg class="broken-link-icon" style="display:none; width:16px; height:16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
516
+ <path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
517
+ </svg>
518
+ </button>
519
+ `;
520
+ }
521
+ }
522
+
523
+ // Register the custom element only if it hasn't been registered yet
524
+ if (!customElements.get("wio-bank-account")) {
525
+ customElements.define("wio-bank-account", WioBankAccount);
526
+ }
527
+
528
+ // Export for module usage
529
+ if (typeof module !== "undefined" && module.exports) {
530
+ module.exports = { WioBankAccount };
531
+ }
532
+
533
+ // Make available globally for script tag usage
534
+ if (typeof window !== "undefined") {
535
+ window.WioBankAccount = WioBankAccount;
536
+ }