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,2054 @@
1
+ /**
2
+ * WioPaymentLinking Web Component
3
+ *
4
+ * A simple web component that provides a button to open a modal and
5
+ * 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-payment-linking.js"></script>
16
+ *
17
+ * <wio-payment-linking id="linking" email="user@example.com" button-text="Link Bank Account"></wio-payment-linking>
18
+ * <script>
19
+ * const linking = document.getElementById('linking');
20
+ * linking.addEventListener('payment-linking-success', (e) => {
21
+ * console.log('Account data:', e.detail);
22
+ * });
23
+ * linking.addEventListener('payment-linking-error', (e) => {
24
+ * console.error('Error:', e.detail);
25
+ * });
26
+ * </script>
27
+ * ```
28
+ */
29
+
30
+ class WioPaymentLinking extends HTMLElement {
31
+ constructor() {
32
+ super();
33
+ this.attachShadow({ mode: "open" });
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
+ "WioPaymentLinking: BisonJibPayAPI is not available. Please ensure component.js is loaded before wio-payment-linking.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 Payment",
57
+ isOpen: false,
58
+ isLoading: false,
59
+ accountData: null,
60
+ moovAccountId: null,
61
+ error: null,
62
+ plaidLoaded: false,
63
+ plaidLinkToken: null,
64
+ initializationError: false,
65
+ // Payment methods from API
66
+ bankAccounts: [],
67
+ isLoadingPaymentMethods: false,
68
+ isRefetchingPaymentMethods: false,
69
+ paymentMethodsError: null,
70
+ // Delete confirmation modal
71
+ deleteConfirmation: {
72
+ isOpen: false,
73
+ accountId: null,
74
+ account: null,
75
+ isDeleting: false,
76
+ },
77
+ };
78
+
79
+ // Render the component
80
+ this.render();
81
+ }
82
+
83
+ // ==================== STATIC PROPERTIES ====================
84
+
85
+ static get observedAttributes() {
86
+ return ["email", "api-base-url", "embeddable-key", "button-text"];
87
+ }
88
+
89
+ // ==================== PROPERTY GETTERS/SETTERS ====================
90
+
91
+ /**
92
+ * Get the email
93
+ * @returns {string|null}
94
+ */
95
+ get email() {
96
+ return this._state.email;
97
+ }
98
+
99
+ /**
100
+ * Get the moovAccountId
101
+ * @returns {string|null}
102
+ */
103
+ get moovAccountId() {
104
+ return this._state.moovAccountId;
105
+ }
106
+
107
+ /**
108
+ * Get the button text
109
+ * @returns {string}
110
+ */
111
+ get buttonText() {
112
+ return this._state.buttonText;
113
+ }
114
+
115
+ /**
116
+ * Set the email
117
+ * @param {string} value - Email address
118
+ */
119
+ set email(value) {
120
+ console.log("WioPaymentLinking: Setting email to:", value);
121
+
122
+ const oldEmail = this._state.email;
123
+
124
+ // Update internal state
125
+ this._state.email = value;
126
+
127
+ // Update attribute only if different to prevent circular updates
128
+ const currentAttr = this.getAttribute("email");
129
+ if (currentAttr !== value) {
130
+ if (value) {
131
+ this.setAttribute("email", value);
132
+ } else {
133
+ this.removeAttribute("email");
134
+ }
135
+ }
136
+
137
+ console.log("WioPaymentLinking: Email state after set:", this._state.email);
138
+
139
+ // Trigger initialization if email changed and component is connected
140
+ if (value && value !== oldEmail && this.isConnected) {
141
+ this.initializeAccount();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Set the button text
147
+ * @param {string} value - Button text
148
+ */
149
+ set buttonText(value) {
150
+ const nextValue = value == null ? "" : String(value);
151
+ const oldValue = this._state.buttonText;
152
+
153
+ this._state.buttonText = nextValue || "Link Payment";
154
+
155
+ const currentAttr = this.getAttribute("button-text");
156
+ if (currentAttr !== nextValue) {
157
+ if (nextValue) {
158
+ this.setAttribute("button-text", nextValue);
159
+ } else {
160
+ this.removeAttribute("button-text");
161
+ }
162
+ }
163
+
164
+ if (oldValue !== this._state.buttonText) {
165
+ this.updateButtonLabel();
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Get the open state
171
+ * @returns {boolean}
172
+ */
173
+ get isOpen() {
174
+ return this._state.isOpen;
175
+ }
176
+
177
+ // ==================== LIFECYCLE METHODS ====================
178
+
179
+ connectedCallback() {
180
+ // Initialize email from attribute if present
181
+ const emailAttr = this.getAttribute("email");
182
+ if (emailAttr && !this._state.email) {
183
+ this._state.email = emailAttr;
184
+ }
185
+
186
+ // Load Plaid SDK
187
+ this.ensurePlaidSDK();
188
+
189
+ this.setupEventListeners();
190
+
191
+ // Auto-initialize if email is already set
192
+ if (this._state.email) {
193
+ this.initializeAccount();
194
+ }
195
+ }
196
+
197
+ disconnectedCallback() {
198
+ this.removeEventListeners();
199
+ }
200
+
201
+ attributeChangedCallback(name, oldValue, newValue) {
202
+ if (oldValue === newValue) return;
203
+
204
+ switch (name) {
205
+ case "email":
206
+ console.log(
207
+ "WioPaymentLinking: attributeChangedCallback - email:",
208
+ newValue
209
+ );
210
+ this._state.email = newValue;
211
+ // Trigger initialization when email attribute changes
212
+ if (newValue && this.isConnected) {
213
+ this.initializeAccount();
214
+ }
215
+ break;
216
+
217
+ case "api-base-url":
218
+ this.apiBaseURL = newValue;
219
+ if (this.api) {
220
+ this.api = new BisonJibPayAPI(this.apiBaseURL, this.embeddableKey);
221
+ }
222
+ break;
223
+
224
+ case "embeddable-key":
225
+ this.embeddableKey = newValue;
226
+ if (this.api) {
227
+ this.api = new BisonJibPayAPI(this.apiBaseURL, this.embeddableKey);
228
+ }
229
+ break;
230
+
231
+ case "button-text":
232
+ this._state.buttonText = newValue || "Link Payment";
233
+ this.updateButtonLabel();
234
+ break;
235
+ }
236
+ }
237
+
238
+ // ==================== PLAID SDK LOADING ====================
239
+
240
+ /**
241
+ * Ensure Plaid SDK is loaded
242
+ *
243
+ * This method dynamically loads the Plaid Link SDK from the CDN if not already present.
244
+ * This eliminates the need for consumers to manually include the script tag.
245
+ *
246
+ * @returns {Promise<void>} Resolves when SDK is ready
247
+ */
248
+ async ensurePlaidSDK() {
249
+ // Check if Plaid is already loaded
250
+ if (window.Plaid) {
251
+ console.log("WioPaymentLinking: Plaid SDK already loaded");
252
+ this._state.plaidLoaded = true;
253
+ return Promise.resolve();
254
+ }
255
+
256
+ // Check if script is already being loaded
257
+ const existingScript = document.querySelector(
258
+ 'script[src*="plaid.com/link"]'
259
+ );
260
+ if (existingScript) {
261
+ console.log(
262
+ "WioPaymentLinking: Plaid SDK script found, waiting for load..."
263
+ );
264
+ return new Promise((resolve, reject) => {
265
+ existingScript.addEventListener("load", () => {
266
+ this._state.plaidLoaded = true;
267
+ resolve();
268
+ });
269
+ existingScript.addEventListener("error", () =>
270
+ reject(new Error("Failed to load Plaid SDK"))
271
+ );
272
+ });
273
+ }
274
+
275
+ // Load the SDK
276
+ console.log("WioPaymentLinking: Loading Plaid SDK from CDN...");
277
+ return new Promise((resolve, reject) => {
278
+ const script = document.createElement("script");
279
+ script.src = "https://cdn.plaid.com/link/v2/stable/link-initialize.js";
280
+ script.async = true;
281
+ script.defer = true;
282
+
283
+ script.onload = () => {
284
+ console.log("WioPaymentLinking: Plaid SDK loaded successfully");
285
+ this._state.plaidLoaded = true;
286
+ resolve();
287
+ };
288
+
289
+ script.onerror = () => {
290
+ const error = new Error("Failed to load Plaid SDK from CDN");
291
+ console.error("WioPaymentLinking:", error);
292
+ this._state.error = error.message;
293
+ this.dispatchEvent(
294
+ new CustomEvent("payment-linking-error", {
295
+ detail: {
296
+ error: error.message,
297
+ type: "sdk",
298
+ },
299
+ bubbles: true,
300
+ composed: true,
301
+ })
302
+ );
303
+ reject(error);
304
+ };
305
+
306
+ // Append to document head
307
+ document.head.appendChild(script);
308
+ });
309
+ }
310
+
311
+ // ==================== EVENT HANDLING ====================
312
+
313
+ setupEventListeners() {
314
+ const button = this.shadowRoot.querySelector(".link-payment-btn");
315
+ const closeBtn = this.shadowRoot.querySelector(".close-btn");
316
+ const overlay = this.shadowRoot.querySelector(".modal-overlay");
317
+ const addBankBtn = this.shadowRoot.querySelector(".add-bank-btn");
318
+
319
+ if (button) {
320
+ button.addEventListener("click", this.handleButtonClick.bind(this));
321
+ }
322
+
323
+ if (closeBtn) {
324
+ closeBtn.addEventListener("click", this.closeModal.bind(this));
325
+ }
326
+
327
+ if (overlay) {
328
+ overlay.addEventListener("click", this.closeModal.bind(this));
329
+ }
330
+
331
+ if (addBankBtn) {
332
+ addBankBtn.addEventListener("click", this.openPlaidLink.bind(this));
333
+ }
334
+
335
+ // Setup delete button event listeners
336
+ this.setupMenuListeners();
337
+
338
+ // ESC key to close modal
339
+ this._escHandler = (e) => {
340
+ if (e.key === "Escape" && this._state.isOpen) {
341
+ this.closeModal();
342
+ }
343
+ };
344
+ document.addEventListener("keydown", this._escHandler);
345
+ }
346
+
347
+ /**
348
+ * Setup event listeners for delete buttons
349
+ */
350
+ setupMenuListeners() {
351
+ const deleteBtns = this.shadowRoot.querySelectorAll(".delete-btn");
352
+
353
+ deleteBtns.forEach((btn) => {
354
+ btn.addEventListener("click", (e) => {
355
+ e.stopPropagation();
356
+ const accountId = btn.dataset.accountId;
357
+ this.handleDeleteAccount(accountId);
358
+ });
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Handle delete account action - shows confirmation modal
364
+ * @param {string} accountId - Account ID to delete
365
+ */
366
+ handleDeleteAccount(accountId) {
367
+ console.log("WioPaymentLinking: Delete account requested for:", accountId);
368
+
369
+ // Find the account to get details for the confirmation modal
370
+ const account = this._state.bankAccounts.find((a) => a.id === accountId);
371
+
372
+ // Show confirmation modal
373
+ this._state.deleteConfirmation = {
374
+ isOpen: true,
375
+ accountId,
376
+ account,
377
+ isDeleting: false,
378
+ };
379
+ this.updateDeleteConfirmationModal();
380
+ }
381
+
382
+ /**
383
+ * Cancel delete operation
384
+ */
385
+ cancelDelete() {
386
+ this._state.deleteConfirmation = {
387
+ isOpen: false,
388
+ accountId: null,
389
+ account: null,
390
+ isDeleting: false,
391
+ };
392
+ this.updateDeleteConfirmationModal();
393
+ }
394
+
395
+ /**
396
+ * Confirm and execute delete operation
397
+ */
398
+ async confirmDelete() {
399
+ const { accountId, account } = this._state.deleteConfirmation;
400
+
401
+ if (!accountId) {
402
+ console.warn("WioPaymentLinking: No account ID for deletion");
403
+ return;
404
+ }
405
+
406
+ // Set deleting state
407
+ this._state.deleteConfirmation.isDeleting = true;
408
+ this.updateDeleteConfirmationModal();
409
+
410
+ // Dispatch delete event for consumer to handle
411
+ this.dispatchEvent(
412
+ new CustomEvent("payment-method-delete", {
413
+ detail: {
414
+ accountId,
415
+ account,
416
+ },
417
+ bubbles: true,
418
+ composed: true,
419
+ })
420
+ );
421
+
422
+ // Call the API to delete the payment method
423
+ // Use cached moovAccountId to avoid extra API call
424
+ if (this.api && this._state.moovAccountId) {
425
+ try {
426
+ console.log("WioPaymentLinking: Deleting payment method via API...");
427
+ await this.api.deletePaymentMethodByAccountId(
428
+ this._state.moovAccountId,
429
+ accountId
430
+ );
431
+ console.log("WioPaymentLinking: Payment method deleted successfully");
432
+
433
+ // Remove from local state after successful API call
434
+ this._state.bankAccounts = this._state.bankAccounts.filter(
435
+ (a) => a.id !== accountId
436
+ );
437
+
438
+ // Close confirmation modal
439
+ this._state.deleteConfirmation = {
440
+ isOpen: false,
441
+ accountId: null,
442
+ account: null,
443
+ isDeleting: false,
444
+ };
445
+
446
+ this.updateBankAccountsList();
447
+ this.updateDeleteConfirmationModal();
448
+
449
+ // Dispatch success event
450
+ this.dispatchEvent(
451
+ new CustomEvent("payment-method-deleted", {
452
+ detail: {
453
+ accountId,
454
+ account,
455
+ },
456
+ bubbles: true,
457
+ composed: true,
458
+ })
459
+ );
460
+ } catch (error) {
461
+ console.error(
462
+ "WioPaymentLinking: Failed to delete payment method",
463
+ error
464
+ );
465
+
466
+ // Reset deleting state but keep modal open
467
+ this._state.deleteConfirmation.isDeleting = false;
468
+ this.updateDeleteConfirmationModal();
469
+
470
+ // Dispatch error event
471
+ this.dispatchEvent(
472
+ new CustomEvent("payment-method-delete-error", {
473
+ detail: {
474
+ accountId,
475
+ account,
476
+ error:
477
+ error.data?.message ||
478
+ error.message ||
479
+ "Failed to delete payment method",
480
+ },
481
+ bubbles: true,
482
+ composed: true,
483
+ })
484
+ );
485
+ }
486
+ } else {
487
+ // Fallback: remove from local state if no API
488
+ this._state.bankAccounts = this._state.bankAccounts.filter(
489
+ (a) => a.id !== accountId
490
+ );
491
+
492
+ // Close confirmation modal
493
+ this._state.deleteConfirmation = {
494
+ isOpen: false,
495
+ accountId: null,
496
+ account: null,
497
+ isDeleting: false,
498
+ };
499
+
500
+ this.updateBankAccountsList();
501
+ this.updateDeleteConfirmationModal();
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Update the delete confirmation modal in the DOM
507
+ */
508
+ updateDeleteConfirmationModal() {
509
+ const container = this.shadowRoot.querySelector("#deleteConfirmationModal");
510
+ if (container) {
511
+ container.innerHTML = this.renderDeleteConfirmationModal();
512
+ this.setupDeleteConfirmationListeners();
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Setup event listeners for delete confirmation modal
518
+ */
519
+ setupDeleteConfirmationListeners() {
520
+ const cancelBtn = this.shadowRoot.querySelector(".delete-cancel-btn");
521
+ const confirmBtn = this.shadowRoot.querySelector(".delete-confirm-btn");
522
+ const overlay = this.shadowRoot.querySelector(
523
+ ".delete-confirmation-overlay"
524
+ );
525
+
526
+ if (cancelBtn) {
527
+ cancelBtn.addEventListener("click", () => this.cancelDelete());
528
+ }
529
+
530
+ if (confirmBtn) {
531
+ confirmBtn.addEventListener("click", () => this.confirmDelete());
532
+ }
533
+
534
+ if (overlay) {
535
+ overlay.addEventListener("click", () => this.cancelDelete());
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Render the delete confirmation modal
541
+ * @returns {string} HTML string for the modal
542
+ */
543
+ renderDeleteConfirmationModal() {
544
+ const { isOpen, account, isDeleting } = this._state.deleteConfirmation;
545
+
546
+ if (!isOpen) {
547
+ return "";
548
+ }
549
+
550
+ const bankName = account?.bankName || "this payment method";
551
+ const lastFour = account?.lastFourAccountNumber || "****";
552
+
553
+ return `
554
+ <div class="delete-confirmation-overlay"></div>
555
+ <div class="delete-confirmation-dialog">
556
+ <div class="delete-confirmation-icon">
557
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
558
+ <circle cx="12" cy="12" r="10"></circle>
559
+ <line x1="12" y1="8" x2="12" y2="12"></line>
560
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
561
+ </svg>
562
+ </div>
563
+ <h3 class="delete-confirmation-title">Delete Payment Method?</h3>
564
+ <p class="delete-confirmation-message">
565
+ Are you sure you want to delete <strong>${bankName}</strong> ending in <strong>••••${lastFour}</strong>? This action cannot be undone.
566
+ </p>
567
+ <div class="delete-confirmation-actions">
568
+ <button class="delete-cancel-btn" ${isDeleting ? "disabled" : ""
569
+ }>Cancel</button>
570
+ <button class="delete-confirm-btn" ${isDeleting ? "disabled" : ""}>
571
+ ${isDeleting
572
+ ? '<span class="delete-spinner"></span> Deleting...'
573
+ : "Delete"
574
+ }
575
+ </button>
576
+ </div>
577
+ </div>
578
+ `;
579
+ }
580
+
581
+ /**
582
+ * Update the bank accounts list in the DOM
583
+ */
584
+ updateBankAccountsList() {
585
+ const container = this.shadowRoot.querySelector("#bankAccountsList");
586
+ if (container) {
587
+ container.innerHTML = this.renderBankAccounts();
588
+ this.setupMenuListeners();
589
+ }
590
+ }
591
+
592
+ removeEventListeners() {
593
+ if (this._escHandler) {
594
+ document.removeEventListener("keydown", this._escHandler);
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Handle button click - just open modal (initialization happens on email set)
600
+ */
601
+ handleButtonClick() {
602
+ console.log(
603
+ "WioPaymentLinking: Button clicked, current email state:",
604
+ this._state.email
605
+ );
606
+
607
+ // Don't open modal if there's an initialization error
608
+ if (this._state.initializationError) {
609
+ console.warn(
610
+ "WioPaymentLinking: Cannot open modal due to initialization error"
611
+ );
612
+ return;
613
+ }
614
+
615
+ // Don't open modal if still loading
616
+ if (this._state.isLoading) {
617
+ console.warn(
618
+ "WioPaymentLinking: Cannot open modal while initialization is in progress"
619
+ );
620
+ return;
621
+ }
622
+
623
+ // Open modal
624
+ this.openModal();
625
+ }
626
+
627
+ /**
628
+ * Open the modal
629
+ */
630
+ openModal() {
631
+ this._state.isOpen = true;
632
+ const modal = this.shadowRoot.querySelector(".modal");
633
+ if (modal) {
634
+ // Show modal and start animation
635
+ modal.classList.add("show", "animating-in");
636
+
637
+ // Remove animating-in class after animation completes
638
+ setTimeout(() => {
639
+ modal.classList.remove("animating-in");
640
+ }, 200);
641
+ }
642
+
643
+ // Prevent background scrolling when modal is open
644
+ document.body.style.overflow = "hidden";
645
+
646
+ // Fetch payment methods when modal opens
647
+ this.fetchPaymentMethods();
648
+ }
649
+
650
+ /**
651
+ * Fetch payment methods from API
652
+ * Uses cached moovAccountId when available to avoid extra API calls
653
+ * @param {boolean} isRefetch - Whether this is a refetch (keeps existing list visible)
654
+ */
655
+ async fetchPaymentMethods(isRefetch = false) {
656
+ if (!this._state.moovAccountId) {
657
+ console.warn(
658
+ "WioPaymentLinking: moovAccountId is required to fetch payment methods. Ensure initializeAccount() has completed."
659
+ );
660
+ return;
661
+ }
662
+
663
+ if (!this.api) {
664
+ console.error("WioPaymentLinking: API not available");
665
+ this._state.paymentMethodsError = "API not available";
666
+ this.updateBankAccountsList();
667
+ return;
668
+ }
669
+
670
+ try {
671
+ // Only show full loading state for initial load, not refetch
672
+ if (!isRefetch) {
673
+ this._state.isLoadingPaymentMethods = true;
674
+ }
675
+ this._state.paymentMethodsError = null;
676
+ this.updateBankAccountsList();
677
+
678
+ console.log(
679
+ "WioPaymentLinking: Fetching payment methods for moovAccountId:",
680
+ this._state.moovAccountId
681
+ );
682
+
683
+ // Use the cached moovAccountId directly to avoid extra API call
684
+ const response = await this.api.getPaymentMethodsByAccountId(
685
+ this._state.moovAccountId
686
+ );
687
+
688
+ if (response.success && response.data) {
689
+ // Filter to only include payment methods with paymentMethodType "ach-credit-same-day"
690
+ const achCreditSameDayMethods = response.data.filter(
691
+ (method) => method.paymentMethodType === "ach-credit-same-day"
692
+ );
693
+
694
+ // Transform API response to match the expected format
695
+ this._state.bankAccounts = achCreditSameDayMethods.map((method) => ({
696
+ // Use the correct ID for deletion based on payment method type
697
+ id: this.getPaymentMethodId(method),
698
+ paymentMethodType: method.paymentMethodType,
699
+ bankName: this.getBankName(method),
700
+ holderName: this.getHolderName(method),
701
+ bankAccountType: method.bankAccount?.bankAccountType || "checking",
702
+ lastFourAccountNumber: this.getLastFour(method),
703
+ status: this.getPaymentMethodStatus(method),
704
+ // Keep original data for reference
705
+ _original: method,
706
+ }));
707
+
708
+ console.log(
709
+ "WioPaymentLinking: Payment methods fetched successfully",
710
+ this._state.bankAccounts
711
+ );
712
+ } else {
713
+ this._state.bankAccounts = [];
714
+ }
715
+
716
+ this._state.isLoadingPaymentMethods = false;
717
+ this.updateBankAccountsList();
718
+ } catch (error) {
719
+ console.error(
720
+ "WioPaymentLinking: Failed to fetch payment methods",
721
+ error
722
+ );
723
+ this._state.isLoadingPaymentMethods = false;
724
+ this._state.paymentMethodsError =
725
+ error.data?.message ||
726
+ error.message ||
727
+ "Failed to fetch payment methods";
728
+ this.updateBankAccountsList();
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Get bank name from payment method
734
+ * @param {Object} method - Payment method data
735
+ * @returns {string} Bank name
736
+ */
737
+ getBankName(method) {
738
+ if (method.bankAccount) {
739
+ return method.bankAccount.bankName || "Bank Account";
740
+ }
741
+ if (method.card) {
742
+ return method.card.brand || method.card.cardType || "Card";
743
+ }
744
+ if (method.wallet || method.paymentMethodType === "moovWallet") {
745
+ return "Moov Wallet";
746
+ }
747
+ if (method.applePay) {
748
+ return "Apple Pay";
749
+ }
750
+ return "Payment Method";
751
+ }
752
+
753
+ /**
754
+ * Get holder name from payment method
755
+ * @param {Object} method - Payment method data
756
+ * @returns {string} Holder name
757
+ */
758
+ getHolderName(method) {
759
+ if (method.bankAccount) {
760
+ return method.bankAccount.holderName || "Account Holder";
761
+ }
762
+ if (method.card) {
763
+ return method.card.holderName || "Card Holder";
764
+ }
765
+ return "Account Holder";
766
+ }
767
+
768
+ /**
769
+ * Get last four digits from payment method
770
+ * @param {Object} method - Payment method data
771
+ * @returns {string} Last four digits
772
+ */
773
+ getLastFour(method) {
774
+ if (method.bankAccount) {
775
+ return method.bankAccount.lastFourAccountNumber || "****";
776
+ }
777
+ if (method.card) {
778
+ return method.card.lastFourCardNumber || "****";
779
+ }
780
+ return "****";
781
+ }
782
+
783
+ /**
784
+ * Get payment method status
785
+ * @param {Object} method - Payment method data
786
+ * @returns {string} Status
787
+ */
788
+ getPaymentMethodStatus(method) {
789
+ if (method.bankAccount) {
790
+ return method.bankAccount.status || "verified";
791
+ }
792
+ return "verified";
793
+ }
794
+
795
+ /**
796
+ * Get the correct ID for a payment method based on its type
797
+ * Used for deletion - bank accounts use bankAccountID, wallets use walletID
798
+ * @param {Object} method - Payment method data
799
+ * @returns {string} The ID to use for deletion
800
+ */
801
+ getPaymentMethodId(method) {
802
+ if (method.bankAccount) {
803
+ return method.bankAccount.bankAccountID;
804
+ }
805
+ if (method.wallet) {
806
+ return method.wallet.walletID;
807
+ }
808
+ if (method.card) {
809
+ return method.card.cardID;
810
+ }
811
+ // Fallback to paymentMethodID if no specific ID is found
812
+ return method.paymentMethodID;
813
+ }
814
+
815
+ /**
816
+ * Close the modal
817
+ */
818
+ closeModal() {
819
+ this._state.isOpen = false;
820
+ this._state.isLoading = false;
821
+ const modal = this.shadowRoot.querySelector(".modal");
822
+ if (modal) {
823
+ // Start close animation
824
+ modal.classList.add("animating-out");
825
+
826
+ // Hide modal after animation completes
827
+ setTimeout(() => {
828
+ modal.classList.remove("show", "animating-out");
829
+ }, 150);
830
+ }
831
+
832
+ // Restore background scrolling when modal is closed
833
+ document.body.style.overflow = "";
834
+
835
+ // Dispatch close event
836
+ this.dispatchEvent(
837
+ new CustomEvent("payment-linking-close", {
838
+ detail: {
839
+ moovAccountId: this._state.moovAccountId,
840
+ accountData: this._state.accountData,
841
+ },
842
+ bubbles: true,
843
+ composed: true,
844
+ })
845
+ );
846
+ }
847
+
848
+ // ==================== INITIALIZATION METHODS ====================
849
+
850
+ /**
851
+ * Initialize account - auto-called when email is set
852
+ * Fetches account data and generates Plaid token
853
+ */
854
+ async initializeAccount() {
855
+ // Validate email
856
+ if (!this._state.email) {
857
+ console.warn("WioPaymentLinking: Email is required for initialization");
858
+ return;
859
+ }
860
+
861
+ // Validate API availability
862
+ if (!this.api) {
863
+ console.error(
864
+ "WioPaymentLinking: BisonJibPayAPI is not available. Please ensure component.js is loaded first."
865
+ );
866
+ this._state.initializationError = true;
867
+ this.updateMainButtonState();
868
+ return;
869
+ }
870
+
871
+ try {
872
+ this._state.isLoading = true;
873
+ this._state.error = null;
874
+ this._state.initializationError = false;
875
+
876
+ // Update button to loading state
877
+ this.updateMainButtonState();
878
+
879
+ console.log(
880
+ "WioPaymentLinking: Initializing account for",
881
+ this._state.email
882
+ );
883
+
884
+ // Fetch account by email
885
+ const result = await this.api.getAccountByEmail(this._state.email);
886
+ this._state.accountData = result.data;
887
+ this._state.moovAccountId = result.data.moovAccountId || null;
888
+
889
+ console.log(
890
+ "WioPaymentLinking: Account fetched successfully",
891
+ result.data
892
+ );
893
+
894
+ // Reset error state after successful account fetch
895
+ this.updateMainButtonState();
896
+ console.log(
897
+ "WioPaymentLinking: Stored moovAccountId:",
898
+ this._state.moovAccountId
899
+ );
900
+
901
+ // Dispatch success event
902
+ this.dispatchEvent(
903
+ new CustomEvent("payment-linking-success", {
904
+ detail: {
905
+ ...result.data,
906
+ moovAccountId: this._state.moovAccountId,
907
+ },
908
+ bubbles: true,
909
+ composed: true,
910
+ })
911
+ );
912
+
913
+ // Generate Plaid Link token
914
+ await this.initializePlaidToken();
915
+ } catch (error) {
916
+ this._state.isLoading = false;
917
+ this._state.error = error.message || "Failed to fetch account data";
918
+ this._state.initializationError = true;
919
+
920
+ console.error("WioPaymentLinking: Account initialization failed", error);
921
+
922
+ // Update button to error state
923
+ this.updateMainButtonState();
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Initialize Plaid token - called after account fetch succeeds
929
+ */
930
+ async initializePlaidToken() {
931
+ try {
932
+ console.log("WioPaymentLinking: Generating Plaid Link token...");
933
+ const plaidLinkResult = await this.api.generatePlaidToken(
934
+ this._state.email
935
+ );
936
+
937
+ if (!plaidLinkResult.success) {
938
+ throw new Error(
939
+ plaidLinkResult.message ||
940
+ "Error occurred while generating Plaid Link token"
941
+ );
942
+ }
943
+
944
+ this._state.plaidLinkToken = plaidLinkResult.data.linkToken;
945
+ this._state.isLoading = false;
946
+ console.log("WioPaymentLinking: Plaid Link token generated successfully");
947
+
948
+ // Ensure button is enabled after successful initialization
949
+ this.updateMainButtonState();
950
+ } catch (error) {
951
+ this._state.isLoading = false;
952
+ this._state.error = error.message || "Failed to generate Plaid token";
953
+ this._state.initializationError = true;
954
+
955
+ console.error("WioPaymentLinking: Plaid token generation failed", error);
956
+
957
+ // Update button to error state
958
+ this.updateMainButtonState();
959
+ }
960
+ }
961
+
962
+ /**
963
+ * Open Plaid Link - triggered by Add Bank Account button click
964
+ */
965
+ async openPlaidLink() {
966
+ // Ensure Plaid SDK is loaded
967
+ if (!this._state.plaidLoaded) {
968
+ console.log("WioPaymentLinking: Plaid SDK not loaded yet, waiting...");
969
+ try {
970
+ await this.ensurePlaidSDK();
971
+ } catch (error) {
972
+ console.error("WioPaymentLinking: Failed to load Plaid SDK:", error);
973
+ return;
974
+ }
975
+ }
976
+
977
+ // Validate token is available
978
+ if (!this._state.plaidLinkToken) {
979
+ console.error("WioPaymentLinking: Plaid Link token not available");
980
+ return;
981
+ }
982
+
983
+ console.log("WioPaymentLinking: Opening Plaid Link...");
984
+
985
+ // Create Plaid handler
986
+ const handler = window.Plaid.create({
987
+ token: this._state.plaidLinkToken,
988
+ onSuccess: async (public_token, metadata) => {
989
+ const moovAccountId = this._state.moovAccountId;
990
+
991
+ if (!moovAccountId) {
992
+ this.dispatchEvent(
993
+ new CustomEvent("payment-account-search-error", {
994
+ detail: {
995
+ error: "Moov Account ID not found",
996
+ type: "api",
997
+ },
998
+ bubbles: true,
999
+ composed: true,
1000
+ })
1001
+ );
1002
+ return;
1003
+ }
1004
+
1005
+ // Show refetching state immediately when Plaid Link closes
1006
+ this._state.isRefetchingPaymentMethods = true;
1007
+ this.updateBankAccountsList();
1008
+
1009
+ console.log(
1010
+ "WioPaymentLinking: Plaid Link onSuccess - showing loading indicator"
1011
+ );
1012
+
1013
+ // Use requestAnimationFrame to ensure the UI updates before starting async work
1014
+ requestAnimationFrame(async () => {
1015
+ try {
1016
+ console.log("WioPaymentLinking: Adding Plaid account to Moov...");
1017
+
1018
+ const result = await this.api.addPlaidAccountToMoov(
1019
+ public_token,
1020
+ metadata.account_id,
1021
+ moovAccountId
1022
+ );
1023
+
1024
+ console.log("WioPaymentLinking: Plaid Link success", result);
1025
+
1026
+ // Refetch payment methods to show the newly added payment method
1027
+ await this.fetchPaymentMethods(true);
1028
+ this._state.isRefetchingPaymentMethods = false;
1029
+ this.updateBankAccountsList();
1030
+
1031
+ this.dispatchEvent(
1032
+ new CustomEvent("plaid-link-success", {
1033
+ detail: { public_token, metadata, result },
1034
+ bubbles: true,
1035
+ composed: true,
1036
+ })
1037
+ );
1038
+ } catch (error) {
1039
+ console.error(
1040
+ "WioPaymentLinking: Failed to add Plaid account to Moov",
1041
+ error
1042
+ );
1043
+
1044
+ // Reset refetching state on error
1045
+ this._state.isRefetchingPaymentMethods = false;
1046
+ this.updateBankAccountsList();
1047
+
1048
+ this.dispatchEvent(
1049
+ new CustomEvent("plaid-link-error", {
1050
+ detail: { error: error.message, metadata },
1051
+ bubbles: true,
1052
+ composed: true,
1053
+ })
1054
+ );
1055
+ }
1056
+ });
1057
+ },
1058
+ onExit: (err, metadata) => {
1059
+ console.log("WioPaymentLinking: Plaid Link exit", err, metadata);
1060
+ if (err) {
1061
+ this.dispatchEvent(
1062
+ new CustomEvent("plaid-link-error", {
1063
+ detail: { error: err, metadata },
1064
+ bubbles: true,
1065
+ composed: true,
1066
+ })
1067
+ );
1068
+ }
1069
+ },
1070
+ onEvent: (eventName, metadata) => {
1071
+ console.log("WioPaymentLinking: Plaid Link event", eventName, metadata);
1072
+ },
1073
+ });
1074
+
1075
+ // Open Plaid Link
1076
+ handler.open();
1077
+ }
1078
+
1079
+ /**
1080
+ * Update main button state based on initialization status
1081
+ */
1082
+ updateMainButtonState() {
1083
+ const button = this.shadowRoot.querySelector(".link-payment-btn");
1084
+ const wrapper = this.shadowRoot.querySelector(".btn-wrapper");
1085
+ if (!button) return;
1086
+
1087
+ // Handle loading state
1088
+ if (this._state.isLoading) {
1089
+ button.classList.add("loading");
1090
+ button.classList.remove("error");
1091
+ button.disabled = true;
1092
+ if (wrapper) wrapper.classList.remove("has-error");
1093
+ }
1094
+ // Handle error state
1095
+ else if (this._state.initializationError) {
1096
+ button.classList.remove("loading");
1097
+ button.classList.add("error");
1098
+ button.disabled = true;
1099
+ if (wrapper) wrapper.classList.add("has-error");
1100
+ }
1101
+ // Handle normal state
1102
+ else {
1103
+ button.classList.remove("loading");
1104
+ button.classList.remove("error");
1105
+ button.disabled = false;
1106
+ if (wrapper) wrapper.classList.remove("has-error");
1107
+ }
1108
+ }
1109
+
1110
+ /**
1111
+ * Update main button label text
1112
+ */
1113
+ updateButtonLabel() {
1114
+ const label = this.shadowRoot.querySelector(".link-payment-label");
1115
+ if (label) {
1116
+ label.textContent = this._state.buttonText || "Link Payment";
1117
+ }
1118
+ }
1119
+
1120
+ // ==================== RENDERING ====================
1121
+
1122
+ /**
1123
+ * Get account type label
1124
+ * @param {string} type - Account type (checking/savings)
1125
+ * @returns {string} Formatted label
1126
+ */
1127
+ getAccountTypeLabel(type) {
1128
+ const labels = {
1129
+ checking: "Checking",
1130
+ savings: "Savings",
1131
+ };
1132
+ return labels[type] || type;
1133
+ }
1134
+
1135
+ /**
1136
+ * Mask account number
1137
+ * @param {string} lastFour - Last 4 digits of account number
1138
+ * @returns {string} Masked account number
1139
+ */
1140
+ maskAccountNumber(lastFour) {
1141
+ return `••••${lastFour}`;
1142
+ }
1143
+
1144
+ /**
1145
+ * Render bank account cards
1146
+ * @returns {string} HTML string for bank accounts
1147
+ */
1148
+ renderBankAccounts() {
1149
+ // Show full loading state only for initial load (not refetch)
1150
+ if (
1151
+ this._state.isLoadingPaymentMethods &&
1152
+ !this._state.isRefetchingPaymentMethods
1153
+ ) {
1154
+ return `
1155
+ <div class="loading-state">
1156
+ <div class="loading-spinner-large"></div>
1157
+ <p>Loading payment methods...</p>
1158
+ </div>
1159
+ `;
1160
+ }
1161
+
1162
+ // Show error state
1163
+ if (this._state.paymentMethodsError) {
1164
+ return `
1165
+ <div class="error-state">
1166
+ <svg class="error-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1167
+ <circle cx="12" cy="12" r="10"></circle>
1168
+ <line x1="12" y1="8" x2="12" y2="12"></line>
1169
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
1170
+ </svg>
1171
+ <p>${this._state.paymentMethodsError}</p>
1172
+ <button class="retry-btn" onclick="this.getRootNode().host.fetchPaymentMethods()">Retry</button>
1173
+ </div>
1174
+ `;
1175
+ }
1176
+
1177
+ const accounts = this._state.bankAccounts || [];
1178
+
1179
+ // Refetching banner (non-intrusive, shown above existing list)
1180
+ const refetchingBanner = this._state.isRefetchingPaymentMethods
1181
+ ? `
1182
+ <div class="refetching-banner">
1183
+ <div class="refetching-spinner"></div>
1184
+ <span>Fetching new payment method...</span>
1185
+ </div>
1186
+ `
1187
+ : "";
1188
+
1189
+ if (accounts.length === 0) {
1190
+ return `
1191
+ ${refetchingBanner}
1192
+ <div class="empty-state">
1193
+ <svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
1194
+ <rect x="3" y="10" width="18" height="11" rx="2" ry="2"></rect>
1195
+ <path d="M12 3L2 10h20L12 3z"></path>
1196
+ </svg>
1197
+ <p>No bank accounts linked yet</p>
1198
+ </div>
1199
+ `;
1200
+ }
1201
+
1202
+ return (
1203
+ refetchingBanner +
1204
+ accounts
1205
+ .map(
1206
+ (account, index) => `
1207
+ <div class="bank-account-card" data-account-id="${account.id}">
1208
+ <div class="bank-account-info">
1209
+ <div class="bank-icon-wrapper">
1210
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1211
+ <rect x="3" y="10" width="18" height="11" rx="2" ry="2"></rect>
1212
+ <path d="M12 3L2 10h20L12 3z"></path>
1213
+ <line x1="12" y1="14" x2="12" y2="17"></line>
1214
+ <line x1="7" y1="14" x2="7" y2="17"></line>
1215
+ <line x1="17" y1="14" x2="17" y2="17"></line>
1216
+ </svg>
1217
+ </div>
1218
+ <div class="bank-account-details">
1219
+ <div class="bank-name-row">
1220
+ <span class="bank-name">${account.bankName}</span>
1221
+ ${account.status === "verified"
1222
+ ? `
1223
+ <svg class="verified-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none">
1224
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
1225
+ </svg>
1226
+ `
1227
+ : ""
1228
+ }
1229
+ </div>
1230
+ <span class="holder-name">${account.holderName}</span>
1231
+ <span class="account-meta">${this.getAccountTypeLabel(
1232
+ account.bankAccountType
1233
+ )} • ${this.maskAccountNumber(account.lastFourAccountNumber)}</span>
1234
+ </div>
1235
+ </div>
1236
+ <div class="card-actions">
1237
+ <span class="status-badge ${account.status}">${account.status}</span>
1238
+ <button class="delete-btn" data-account-id="${account.id
1239
+ }" aria-label="Delete payment method">
1240
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1241
+ <polyline points="3 6 5 6 21 6"></polyline>
1242
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
1243
+ <line x1="10" y1="11" x2="10" y2="17"></line>
1244
+ <line x1="14" y1="11" x2="14" y2="17"></line>
1245
+ </svg>
1246
+ </button>
1247
+ </div>
1248
+ </div>
1249
+ `
1250
+ )
1251
+ .join("")
1252
+ );
1253
+ }
1254
+
1255
+ /**
1256
+ * Render the component (Shadow DOM)
1257
+ */
1258
+ render() {
1259
+ this.shadowRoot.innerHTML = `
1260
+ <style>
1261
+ :host {
1262
+ display: inline-block;
1263
+ font-family: var(--font-sans, 'Inter', system-ui, sans-serif);
1264
+ color: var(--color-secondary, #5f6e78);
1265
+ }
1266
+
1267
+ .link-payment-btn {
1268
+ padding: 12px 24px;
1269
+ background: var(--color-primary, #4c7b63);
1270
+ color: var(--color-white, #fff);
1271
+ border: none;
1272
+ border-radius: var(--radius-xl, 0.75rem);
1273
+ font-size: var(--text-sm, 0.875rem);
1274
+ font-weight: var(--font-weight-medium, 500);
1275
+ cursor: pointer;
1276
+ transition: all var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
1277
+ display: inline-flex;
1278
+ align-items: center;
1279
+ gap: 8px;
1280
+ height: 40px;
1281
+ box-sizing: border-box;
1282
+ }
1283
+
1284
+ .link-payment-btn:hover:not(.error):not(.loading) {
1285
+ background: var(--color-primary-hover, #436c57);
1286
+ }
1287
+
1288
+ .link-payment-btn:active:not(.error):not(.loading) {
1289
+ background: var(--color-primary-active, #3d624f);
1290
+ transform: translateY(0);
1291
+ }
1292
+
1293
+ .link-payment-btn.error {
1294
+ background: var(--color-gray-400, #9ca3af);
1295
+ cursor: not-allowed;
1296
+ }
1297
+
1298
+ .link-payment-btn.loading {
1299
+ background: var(--color-primary-soft, #678f7a);
1300
+ cursor: wait;
1301
+ }
1302
+
1303
+ .link-payment-btn .broken-link-icon {
1304
+ display: none;
1305
+ }
1306
+
1307
+ .link-payment-btn.error .broken-link-icon {
1308
+ display: inline-block;
1309
+ }
1310
+
1311
+ .link-payment-btn .loading-spinner {
1312
+ display: none;
1313
+ width: 16px;
1314
+ height: 16px;
1315
+ border: 2px solid rgba(255, 255, 255, 0.3);
1316
+ border-top-color: var(--color-white, #fff);
1317
+ border-radius: 50%;
1318
+ animation: spin 0.8s linear infinite;
1319
+ box-sizing: border-box;
1320
+ }
1321
+
1322
+ .link-payment-btn.loading .loading-spinner {
1323
+ display: inline-block;
1324
+ }
1325
+
1326
+ @keyframes spin {
1327
+ to {
1328
+ transform: rotate(360deg);
1329
+ }
1330
+ }
1331
+
1332
+ .btn-wrapper {
1333
+ position: relative;
1334
+ display: inline-block;
1335
+ }
1336
+
1337
+ .tooltip {
1338
+ visibility: hidden;
1339
+ opacity: 0;
1340
+ position: absolute;
1341
+ bottom: 100%;
1342
+ left: 50%;
1343
+ transform: translateX(-50%);
1344
+ background: var(--color-gray-700, #374151);
1345
+ color: var(--color-white, #fff);
1346
+ padding: 8px 12px;
1347
+ border-radius: var(--radius-lg, 0.5rem);
1348
+ font-size: 13px;
1349
+ white-space: nowrap;
1350
+ margin-bottom: 8px;
1351
+ transition: opacity var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1)),
1352
+ visibility var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
1353
+ z-index: 10002;
1354
+ }
1355
+
1356
+ .tooltip::after {
1357
+ content: '';
1358
+ position: absolute;
1359
+ top: 100%;
1360
+ left: 50%;
1361
+ transform: translateX(-50%);
1362
+ border: 6px solid transparent;
1363
+ border-top-color: var(--color-gray-700, #374151);
1364
+ }
1365
+
1366
+ .btn-wrapper:hover .tooltip {
1367
+ visibility: visible;
1368
+ opacity: 1;
1369
+ }
1370
+
1371
+ .btn-wrapper:not(.has-error) .tooltip {
1372
+ display: none;
1373
+ }
1374
+
1375
+ .modal {
1376
+ display: none;
1377
+ position: fixed;
1378
+ top: 0;
1379
+ left: 0;
1380
+ right: 0;
1381
+ bottom: 0;
1382
+ z-index: 10000;
1383
+ align-items: center;
1384
+ justify-content: center;
1385
+ }
1386
+
1387
+ .modal.show {
1388
+ display: flex;
1389
+ }
1390
+
1391
+ .modal.animating-in .modal-overlay {
1392
+ animation: fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1393
+ }
1394
+
1395
+ .modal.animating-in .modal-content {
1396
+ animation: slideInScale 0.2s cubic-bezier(0.4, 0, 0.2, 1);
1397
+ }
1398
+
1399
+ .modal.animating-out .modal-overlay {
1400
+ animation: fadeOut 0.15s cubic-bezier(0.4, 0, 1, 1);
1401
+ }
1402
+
1403
+ .modal.animating-out .modal-content {
1404
+ animation: slideOutScale 0.15s cubic-bezier(0.4, 0, 1, 1);
1405
+ }
1406
+
1407
+ @keyframes fadeIn {
1408
+ from {
1409
+ opacity: 0;
1410
+ }
1411
+ to {
1412
+ opacity: 1;
1413
+ }
1414
+ }
1415
+
1416
+ @keyframes fadeOut {
1417
+ from {
1418
+ opacity: 1;
1419
+ }
1420
+ to {
1421
+ opacity: 0;
1422
+ }
1423
+ }
1424
+
1425
+ @keyframes slideInScale {
1426
+ from {
1427
+ opacity: 0;
1428
+ transform: scale(0.95) translateY(-10px);
1429
+ }
1430
+ to {
1431
+ opacity: 1;
1432
+ transform: scale(1) translateY(0);
1433
+ }
1434
+ }
1435
+
1436
+ @keyframes slideOutScale {
1437
+ from {
1438
+ opacity: 1;
1439
+ transform: scale(1) translateY(0);
1440
+ }
1441
+ to {
1442
+ opacity: 0;
1443
+ transform: scale(0.98) translateY(-8px);
1444
+ }
1445
+ }
1446
+
1447
+ .modal-overlay {
1448
+ position: absolute;
1449
+ top: 0;
1450
+ left: 0;
1451
+ right: 0;
1452
+ bottom: 0;
1453
+ background: rgba(0, 0, 0, 0.5);
1454
+ }
1455
+
1456
+ .modal-content {
1457
+ position: relative;
1458
+ background: var(--color-white, #fff);
1459
+ border-radius: var(--radius-xl, 0.75rem);
1460
+ width: 90%;
1461
+ max-width: 600px;
1462
+ min-height: 400px;
1463
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1464
+ z-index: 10001;
1465
+ display: flex;
1466
+ flex-direction: column;
1467
+ align-items: center;
1468
+ justify-content: center;
1469
+ padding: 40px;
1470
+ }
1471
+
1472
+ .close-btn {
1473
+ position: absolute;
1474
+ top: 16px;
1475
+ right: 16px;
1476
+ background: transparent;
1477
+ border: none;
1478
+ font-size: 28px;
1479
+ color: var(--color-gray-400, #9ca3af);
1480
+ cursor: pointer;
1481
+ width: 32px;
1482
+ height: 32px;
1483
+ display: flex;
1484
+ align-items: center;
1485
+ justify-content: center;
1486
+ border-radius: 4px;
1487
+ transition: all var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
1488
+ }
1489
+
1490
+ .close-btn:hover {
1491
+ background: var(--color-gray-100, #f3f4f6);
1492
+ color: var(--color-headline, #0f2a39);
1493
+ }
1494
+
1495
+ .add-bank-btn {
1496
+ padding: 12px 24px;
1497
+ background: var(--color-primary, #4c7b63);
1498
+ color: var(--color-white, #fff);
1499
+ border: none;
1500
+ border-radius: var(--radius-xl, 0.75rem);
1501
+ font-size: var(--text-sm, 0.875rem);
1502
+ font-weight: var(--font-weight-medium, 500);
1503
+ cursor: pointer;
1504
+ transition: all var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
1505
+ display: inline-flex;
1506
+ align-items: center;
1507
+ gap: 10px;
1508
+ height: 40px;
1509
+ box-sizing: border-box;
1510
+ }
1511
+
1512
+ .add-bank-btn:hover {
1513
+ background: var(--color-primary-hover, #436c57);
1514
+ }
1515
+
1516
+ .add-bank-btn:active {
1517
+ background: var(--color-primary-active, #3d624f);
1518
+ }
1519
+
1520
+ .add-bank-btn .bank-icon {
1521
+ width: 20px;
1522
+ height: 20px;
1523
+ }
1524
+
1525
+ .modal-header {
1526
+ width: 100%;
1527
+ text-align: center;
1528
+ margin-bottom: var(--spacing-lg, 24px);
1529
+ padding-bottom: var(--spacing-md, 16px);
1530
+ border-bottom: 1px solid var(--color-border, #e8e8e8);
1531
+ }
1532
+
1533
+ .modal-header h2 {
1534
+ font-size: 20px;
1535
+ font-weight: var(--font-weight-semibold, 600);
1536
+ color: var(--color-headline, #0f2a39);
1537
+ margin: 0;
1538
+ }
1539
+
1540
+ .modal-header p {
1541
+ font-size: 14px;
1542
+ color: var(--color-gray-500, #6b7280);
1543
+ margin-top: 4px;
1544
+ }
1545
+
1546
+ .bank-accounts-list {
1547
+ width: 100%;
1548
+ display: flex;
1549
+ flex-direction: column;
1550
+ gap: 12px;
1551
+ margin-bottom: 24px;
1552
+ max-height: 300px;
1553
+ overflow-y: auto;
1554
+ }
1555
+
1556
+ .bank-account-card {
1557
+ display: flex;
1558
+ align-items: flex-start;
1559
+ justify-content: space-between;
1560
+ padding: 16px;
1561
+ background: var(--color-gray-50, #f9fafb);
1562
+ border: 1px solid var(--color-border, #e8e8e8);
1563
+ border-radius: var(--radius-xl, 0.75rem);
1564
+ transition: all var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
1565
+ }
1566
+
1567
+ .bank-account-card:hover {
1568
+ border-color: var(--color-primary, #4c7b63);
1569
+ background: var(--color-gray-100, #f3f4f6);
1570
+ }
1571
+
1572
+ .bank-account-info {
1573
+ display: flex;
1574
+ align-items: flex-start;
1575
+ gap: 12px;
1576
+ }
1577
+
1578
+ .bank-icon-wrapper {
1579
+ padding: 10px;
1580
+ background: var(--color-primary-light, #e8f0eb);
1581
+ border-radius: var(--radius-lg, 0.5rem);
1582
+ display: flex;
1583
+ align-items: center;
1584
+ justify-content: center;
1585
+ }
1586
+
1587
+ .bank-icon-wrapper svg {
1588
+ width: 20px;
1589
+ height: 20px;
1590
+ color: var(--color-primary, #4c7b63);
1591
+ stroke: var(--color-primary, #4c7b63);
1592
+ }
1593
+
1594
+ .bank-account-details {
1595
+ display: flex;
1596
+ flex-direction: column;
1597
+ gap: 2px;
1598
+ }
1599
+
1600
+ .bank-name-row {
1601
+ display: flex;
1602
+ align-items: center;
1603
+ gap: 8px;
1604
+ }
1605
+
1606
+ .bank-name {
1607
+ font-weight: 500;
1608
+ font-size: 15px;
1609
+ color: var(--color-headline, #0f2a39);
1610
+ }
1611
+
1612
+ .verified-icon {
1613
+ width: 16px;
1614
+ height: 16px;
1615
+ color: var(--color-success, #22c55e);
1616
+ }
1617
+
1618
+ .holder-name {
1619
+ font-size: 14px;
1620
+ color: var(--color-gray-500, #6b7280);
1621
+ text-align: left;
1622
+ }
1623
+
1624
+ .account-meta {
1625
+ font-size: 12px;
1626
+ color: var(--color-gray-400, #9ca3af);
1627
+ margin-top: 2px;
1628
+ text-align: left;
1629
+ }
1630
+
1631
+ .status-badge {
1632
+ padding: 4px 10px;
1633
+ border-radius: 6px;
1634
+ font-size: 12px;
1635
+ font-weight: 500;
1636
+ text-transform: capitalize;
1637
+ }
1638
+
1639
+ .status-badge.verified {
1640
+ background: var(--color-success-light, #d3f3df);
1641
+ color: var(--color-success-dark, #136c34);
1642
+ }
1643
+
1644
+ .status-badge.pending {
1645
+ background: var(--color-orange-100, #fdecce);
1646
+ color: var(--color-orange-600, #875706);
1647
+ }
1648
+
1649
+ .card-actions {
1650
+ display: flex;
1651
+ align-items: center;
1652
+ gap: 8px;
1653
+ }
1654
+
1655
+ .delete-btn {
1656
+ background: transparent;
1657
+ border: none;
1658
+ padding: 6px;
1659
+ cursor: pointer;
1660
+ border-radius: 6px;
1661
+ display: flex;
1662
+ align-items: center;
1663
+ justify-content: center;
1664
+ transition: all var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
1665
+ color: var(--color-gray-400, #9ca3af);
1666
+ }
1667
+
1668
+ .delete-btn:hover {
1669
+ background: var(--color-error-light, #fae5e4);
1670
+ color: var(--color-error, #dd524b);
1671
+ }
1672
+
1673
+ .delete-btn svg {
1674
+ width: 18px;
1675
+ height: 18px;
1676
+ }
1677
+
1678
+ .empty-state {
1679
+ text-align: center;
1680
+ padding: 32px 16px;
1681
+ color: var(--color-gray-500, #6b7280);
1682
+ }
1683
+
1684
+ .empty-state-icon {
1685
+ width: 48px;
1686
+ height: 48px;
1687
+ margin: 0 auto 12px;
1688
+ color: var(--color-gray-200, #d1d5db);
1689
+ }
1690
+
1691
+ .empty-state p {
1692
+ font-size: 14px;
1693
+ margin: 0;
1694
+ }
1695
+
1696
+ .loading-state {
1697
+ text-align: center;
1698
+ padding: 32px 16px;
1699
+ color: var(--color-gray-500, #6b7280);
1700
+ }
1701
+
1702
+ .loading-spinner-large {
1703
+ width: 32px;
1704
+ height: 32px;
1705
+ border: 3px solid var(--color-border, #e8e8e8);
1706
+ border-top-color: var(--color-primary, #4c7b63);
1707
+ border-radius: 50%;
1708
+ animation: spin 0.8s linear infinite;
1709
+ margin: 0 auto 12px;
1710
+ }
1711
+
1712
+ .loading-state p {
1713
+ font-size: 14px;
1714
+ margin: 0;
1715
+ }
1716
+
1717
+ .error-state {
1718
+ text-align: center;
1719
+ padding: 32px 16px;
1720
+ color: var(--color-error, #dd524b);
1721
+ }
1722
+
1723
+ .error-state-icon {
1724
+ width: 48px;
1725
+ height: 48px;
1726
+ margin: 0 auto 12px;
1727
+ color: var(--color-error, #dd524b);
1728
+ }
1729
+
1730
+ .error-state p {
1731
+ font-size: 14px;
1732
+ margin: 0 0 16px 0;
1733
+ color: var(--color-gray-500, #6b7280);
1734
+ }
1735
+
1736
+ .retry-btn {
1737
+ padding: 8px 16px;
1738
+ background: var(--color-primary, #4c7b63);
1739
+ color: var(--color-white, #fff);
1740
+ border: none;
1741
+ border-radius: var(--radius-lg, 0.5rem);
1742
+ font-size: 13px;
1743
+ font-weight: var(--font-weight-medium, 500);
1744
+ cursor: pointer;
1745
+ transition: all var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
1746
+ }
1747
+
1748
+ .retry-btn:hover {
1749
+ background: var(--color-primary-hover, #436c57);
1750
+ }
1751
+
1752
+ /* Refetching Banner - Non-intrusive loading indicator */
1753
+ .refetching-banner {
1754
+ display: flex;
1755
+ align-items: center;
1756
+ justify-content: center;
1757
+ gap: 10px;
1758
+ padding: 12px 16px;
1759
+ background: linear-gradient(135deg, var(--color-success-light, #d3f3df) 0%, var(--color-primary-light, #e8f0eb) 100%);
1760
+ border: 1px solid var(--color-success-light, #d3f3df);
1761
+ border-radius: var(--radius-lg, 0.5rem);
1762
+ margin-bottom: 12px;
1763
+ animation: refetchSlideIn 0.3s ease-out;
1764
+ }
1765
+
1766
+ @keyframes refetchSlideIn {
1767
+ from {
1768
+ opacity: 0;
1769
+ transform: translateY(-10px);
1770
+ }
1771
+ to {
1772
+ opacity: 1;
1773
+ transform: translateY(0);
1774
+ }
1775
+ }
1776
+
1777
+ .refetching-spinner {
1778
+ width: 16px;
1779
+ height: 16px;
1780
+ border: 2px solid var(--color-success-light, #d3f3df);
1781
+ border-top-color: var(--color-success, #22c55e);
1782
+ border-radius: 50%;
1783
+ animation: spin 0.8s linear infinite;
1784
+ }
1785
+
1786
+ .refetching-banner span {
1787
+ font-size: 13px;
1788
+ font-weight: 500;
1789
+ color: var(--color-success-dark, #136c34);
1790
+ }
1791
+
1792
+ .divider {
1793
+ width: 100%;
1794
+ height: 1px;
1795
+ background: var(--color-border, #e8e8e8);
1796
+ margin: 16px 0;
1797
+ }
1798
+
1799
+ .add-bank-section {
1800
+ width: 100%;
1801
+ display: flex;
1802
+ flex-direction: column;
1803
+ align-items: center;
1804
+ gap: 8px;
1805
+ }
1806
+
1807
+ .add-bank-section p {
1808
+ font-size: 13px;
1809
+ color: var(--color-gray-500, #6b7280);
1810
+ margin: 0;
1811
+ }
1812
+
1813
+ .powered-by {
1814
+ display: flex;
1815
+ align-items: center;
1816
+ justify-content: center;
1817
+ gap: 6px;
1818
+ margin-top: 24px;
1819
+ padding-top: 16px;
1820
+ font-size: 11px;
1821
+ color: var(--color-gray-400, #9ca3af);
1822
+ }
1823
+
1824
+ .powered-by svg {
1825
+ width: 16px;
1826
+ height: 16px;
1827
+ }
1828
+
1829
+ .powered-by span {
1830
+ font-weight: 500;
1831
+ color: var(--color-gray-500, #6b7280);
1832
+ }
1833
+
1834
+ /* Delete Confirmation Modal */
1835
+ .delete-confirmation-container {
1836
+ display: contents;
1837
+ }
1838
+
1839
+ .delete-confirmation-overlay {
1840
+ position: fixed;
1841
+ top: 0;
1842
+ left: 0;
1843
+ right: 0;
1844
+ bottom: 0;
1845
+ background: rgba(0, 0, 0, 0.6);
1846
+ z-index: 10200;
1847
+ }
1848
+
1849
+ .delete-confirmation-dialog {
1850
+ position: fixed;
1851
+ top: 50%;
1852
+ left: 50%;
1853
+ transform: translate(-50%, -50%);
1854
+ background: var(--color-white, #fff);
1855
+ border-radius: var(--radius-2xl, 1rem);
1856
+ padding: 32px;
1857
+ width: 90%;
1858
+ max-width: 400px;
1859
+ z-index: 10201;
1860
+ text-align: center;
1861
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1862
+ animation: deleteModalIn 0.2s ease-out;
1863
+ }
1864
+
1865
+ @keyframes deleteModalIn {
1866
+ from {
1867
+ opacity: 0;
1868
+ transform: translate(-50%, -50%) scale(0.95);
1869
+ }
1870
+ to {
1871
+ opacity: 1;
1872
+ transform: translate(-50%, -50%) scale(1);
1873
+ }
1874
+ }
1875
+
1876
+ .delete-confirmation-icon {
1877
+ width: 56px;
1878
+ height: 56px;
1879
+ margin: 0 auto 16px;
1880
+ background: var(--color-error-light, #fae5e4);
1881
+ border-radius: 50%;
1882
+ display: flex;
1883
+ align-items: center;
1884
+ justify-content: center;
1885
+ }
1886
+
1887
+ .delete-confirmation-icon svg {
1888
+ width: 28px;
1889
+ height: 28px;
1890
+ color: var(--color-error, #dd524b);
1891
+ }
1892
+
1893
+ .delete-confirmation-title {
1894
+ font-size: 18px;
1895
+ font-weight: var(--font-weight-semibold, 600);
1896
+ color: var(--color-headline, #0f2a39);
1897
+ margin: 0 0 8px 0;
1898
+ }
1899
+
1900
+ .delete-confirmation-message {
1901
+ font-size: 14px;
1902
+ color: var(--color-gray-500, #6b7280);
1903
+ margin: 0 0 24px 0;
1904
+ line-height: 1.5;
1905
+ }
1906
+
1907
+ .delete-confirmation-message strong {
1908
+ color: var(--color-gray-700, #374151);
1909
+ }
1910
+
1911
+ .delete-confirmation-actions {
1912
+ display: flex;
1913
+ gap: 12px;
1914
+ justify-content: center;
1915
+ }
1916
+
1917
+ .delete-cancel-btn {
1918
+ padding: 10px 20px;
1919
+ background: var(--color-white, #fff);
1920
+ color: var(--color-gray-700, #374151);
1921
+ border: 1px solid var(--color-gray-200, #d1d5db);
1922
+ border-radius: var(--radius-lg, 0.5rem);
1923
+ font-size: var(--text-sm, 0.875rem);
1924
+ font-weight: var(--font-weight-medium, 500);
1925
+ cursor: pointer;
1926
+ transition: all var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
1927
+ height: 40px;
1928
+ box-sizing: border-box;
1929
+ }
1930
+
1931
+ .delete-cancel-btn:hover:not(:disabled) {
1932
+ background: var(--color-gray-100, #f3f4f6);
1933
+ border-color: var(--color-gray-400, #9ca3af);
1934
+ }
1935
+
1936
+ .delete-cancel-btn:disabled {
1937
+ opacity: 0.5;
1938
+ cursor: not-allowed;
1939
+ }
1940
+
1941
+ .delete-confirm-btn {
1942
+ padding: 10px 20px;
1943
+ background: var(--color-error, #dd524b);
1944
+ color: var(--color-white, #fff);
1945
+ border: none;
1946
+ border-radius: var(--radius-lg, 0.5rem);
1947
+ font-size: var(--text-sm, 0.875rem);
1948
+ font-weight: var(--font-weight-medium, 500);
1949
+ cursor: pointer;
1950
+ transition: all var(--duration-normal, 200ms) var(--ease-in-out, cubic-bezier(0.4, 0, 0.2, 1));
1951
+ display: inline-flex;
1952
+ align-items: center;
1953
+ justify-content: center;
1954
+ gap: 8px;
1955
+ height: 40px;
1956
+ box-sizing: border-box;
1957
+ min-width: 100px;
1958
+ }
1959
+
1960
+ .delete-confirm-btn:hover:not(:disabled) {
1961
+ background: var(--color-error-dark, #903531);
1962
+ }
1963
+
1964
+ .delete-confirm-btn:disabled {
1965
+ background: var(--color-error-muted, #eea9a5);
1966
+ cursor: not-allowed;
1967
+ }
1968
+
1969
+ .delete-spinner {
1970
+ width: 14px;
1971
+ height: 14px;
1972
+ border: 2px solid rgba(255, 255, 255, 0.3);
1973
+ border-top-color: var(--color-white, #fff);
1974
+ border-radius: 50%;
1975
+ animation: spin 0.8s linear infinite;
1976
+ box-sizing: border-box;
1977
+ }
1978
+ </style>
1979
+
1980
+ <!-- Main Button -->
1981
+ <div class="btn-wrapper">
1982
+ <span class="tooltip">User is not onboarded to the Bison system</span>
1983
+ <button class="link-payment-btn">
1984
+ <span class="loading-spinner"></span>
1985
+ <svg class="broken-link-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1986
+ <path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path>
1987
+ <line x1="1" y1="1" x2="23" y2="23"></line>
1988
+ </svg>
1989
+ <span class="link-payment-label">${this._state.buttonText}</span>
1990
+ </button>
1991
+ </div>
1992
+
1993
+ <!-- Modal -->
1994
+ <div class="modal">
1995
+ <div class="modal-overlay"></div>
1996
+ <div class="modal-content">
1997
+ <button class="close-btn">×</button>
1998
+
1999
+ <!-- Modal Header -->
2000
+ <div class="modal-header">
2001
+ <h2>Payment Methods</h2>
2002
+ <p>Manage your linked bank accounts</p>
2003
+ </div>
2004
+
2005
+ <!-- Bank Accounts List -->
2006
+ <div class="bank-accounts-list" id="bankAccountsList">
2007
+ ${this.renderBankAccounts()}
2008
+ </div>
2009
+
2010
+ <!-- Divider -->
2011
+ <div class="divider"></div>
2012
+
2013
+ <!-- Add Bank Section -->
2014
+ <div class="add-bank-section">
2015
+ <button class="add-bank-btn">
2016
+ <svg class="bank-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2017
+ <line x1="12" y1="5" x2="12" y2="19"></line>
2018
+ <line x1="5" y1="12" x2="19" y2="12"></line>
2019
+ </svg>
2020
+ Add Bank Account
2021
+ </button>
2022
+ <p>Connect a new bank account via Plaid</p>
2023
+ </div>
2024
+
2025
+ <!-- Powered by Bison -->
2026
+ <div class="powered-by">
2027
+ Powered by
2028
+ <img src="./bison_logo.png" alt="Bison" style="height: 16px; margin-left: 4px;" onerror="this.onerror=null; this.src='https://bisonpaywell.com/lovable-uploads/28831244-e8b3-4e7b-8dbb-c016f9f9d54f.png';">
2029
+ </div>
2030
+ </div>
2031
+ </div>
2032
+
2033
+ <!-- Delete Confirmation Modal -->
2034
+ <div class="delete-confirmation-container" id="deleteConfirmationModal">
2035
+ ${this.renderDeleteConfirmationModal()}
2036
+ </div>
2037
+ `;
2038
+ }
2039
+ }
2040
+
2041
+ // Register the custom element only if it hasn't been registered yet
2042
+ if (!customElements.get("wio-payment-linking")) {
2043
+ customElements.define("wio-payment-linking", WioPaymentLinking);
2044
+ }
2045
+
2046
+ // Export for module usage
2047
+ if (typeof module !== "undefined" && module.exports) {
2048
+ module.exports = { WioPaymentLinking };
2049
+ }
2050
+
2051
+ // Make available globally for script tag usage
2052
+ if (typeof window !== "undefined") {
2053
+ window.WioPaymentLinking = WioPaymentLinking;
2054
+ }