@chargebee/chargebee-apps-libs 0.0.1

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.
Files changed (49) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +97 -0
  3. package/SECURITY.md +8 -0
  4. package/config/README.md +83 -0
  5. package/config/allowed-modules.json +24 -0
  6. package/dist/config/config-loader.d.ts +24 -0
  7. package/dist/config/config-loader.js +95 -0
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +21 -0
  10. package/dist/libs/port-service.d.ts +18 -0
  11. package/dist/libs/port-service.js +27 -0
  12. package/dist/logger/cb-public-logger.d.ts +59 -0
  13. package/dist/logger/cb-public-logger.js +158 -0
  14. package/dist/sandbox/public-sandbox-wrapper.d.ts +41 -0
  15. package/dist/sandbox/public-sandbox-wrapper.js +208 -0
  16. package/package.json +44 -0
  17. package/templates/serverless-node-starter-app/.env +4 -0
  18. package/templates/serverless-node-starter-app/README.md +290 -0
  19. package/templates/serverless-node-starter-app/handler/handler.js +34 -0
  20. package/templates/serverless-node-starter-app/jsconfig.json +35 -0
  21. package/templates/serverless-node-starter-app/manifest.json +15 -0
  22. package/templates/serverless-node-starter-app/test_data/customer_created.json +51 -0
  23. package/templates/serverless-node-starter-app/test_data/invoice_generated.json +112 -0
  24. package/templates/serverless-node-starter-app/test_data/subscription_created.json +173 -0
  25. package/templates/serverless-node-starter-app/types/types.d.ts +45 -0
  26. package/templates/serverless-node-starter-app-with-iparams/.env +4 -0
  27. package/templates/serverless-node-starter-app-with-iparams/README.md +717 -0
  28. package/templates/serverless-node-starter-app-with-iparams/handler/handler.js +39 -0
  29. package/templates/serverless-node-starter-app-with-iparams/iparams.json +41 -0
  30. package/templates/serverless-node-starter-app-with-iparams/iparams.local.json +9 -0
  31. package/templates/serverless-node-starter-app-with-iparams/jsconfig.json +35 -0
  32. package/templates/serverless-node-starter-app-with-iparams/manifest.json +15 -0
  33. package/templates/serverless-node-starter-app-with-iparams/test_data/customer_created.json +51 -0
  34. package/templates/serverless-node-starter-app-with-iparams/test_data/invoice_generated.json +112 -0
  35. package/templates/serverless-node-starter-app-with-iparams/test_data/subscription_created.json +173 -0
  36. package/templates/serverless-node-starter-app-with-iparams/types/types.d.ts +63 -0
  37. package/ui/README.md +118 -0
  38. package/ui/web/assets/css/main.css +1121 -0
  39. package/ui/web/assets/images/Chargebee-logo.png +0 -0
  40. package/ui/web/assets/js/main.js +61 -0
  41. package/ui/web/assets/js/modules/api.js +97 -0
  42. package/ui/web/assets/js/modules/constants.js +33 -0
  43. package/ui/web/assets/js/modules/editors.js +41 -0
  44. package/ui/web/assets/js/modules/events.js +195 -0
  45. package/ui/web/assets/js/modules/form-utils.js +70 -0
  46. package/ui/web/assets/js/modules/iparams-inputs.js +495 -0
  47. package/ui/web/assets/js/modules/iparams.js +82 -0
  48. package/ui/web/assets/js/modules/ui-utils.js +87 -0
  49. package/ui/web/index.html +164 -0
@@ -0,0 +1,61 @@
1
+ // Main entry point - imports and initializes all modules
2
+ import { initializeCodeMirror, initializeIparamsEditors } from './modules/editors.js';
3
+ import { setupTabNavigation, setupModalListeners, switchTab } from './modules/ui-utils.js';
4
+ import {
5
+ handleEventTypeChange,
6
+ loadAvailableEventTypes,
7
+ resetToOriginalData,
8
+ testEvent
9
+ } from './modules/events.js';
10
+ import {
11
+ enableIparamsEditing,
12
+ cancelIparamsEditing,
13
+ saveIparams
14
+ } from './modules/iparams.js';
15
+ import {
16
+ enableInputsEditing,
17
+ cancelInputsEditing,
18
+ toggleInputsView,
19
+ saveIparamsInputs
20
+ } from './modules/iparams-inputs.js';
21
+ import {
22
+ eventTypeSelect,
23
+ resetBtn,
24
+ testDataBtn,
25
+ saveIparamsBtn,
26
+ saveInputsBtn,
27
+ editIparamsBtn,
28
+ editInputsBtn,
29
+ cancelIparamsBtn,
30
+ cancelInputsBtn,
31
+ toggleViewBtn
32
+ } from './modules/constants.js';
33
+
34
+ /**
35
+ * Sets up event listeners for user interactions
36
+ */
37
+ function setupEventListeners() {
38
+ eventTypeSelect.addEventListener('change', handleEventTypeChange);
39
+ resetBtn.addEventListener('click', resetToOriginalData);
40
+ testDataBtn.addEventListener('click', testEvent);
41
+ saveIparamsBtn.addEventListener('click', saveIparams);
42
+ saveInputsBtn.addEventListener('click', saveIparamsInputs);
43
+ editIparamsBtn.addEventListener('click', enableIparamsEditing);
44
+ editInputsBtn.addEventListener('click', enableInputsEditing);
45
+ cancelIparamsBtn.addEventListener('click', cancelIparamsEditing);
46
+ cancelInputsBtn.addEventListener('click', cancelInputsEditing);
47
+ toggleViewBtn.addEventListener('click', toggleInputsView);
48
+ }
49
+
50
+ // Initialize the application
51
+ document.addEventListener('DOMContentLoaded', function() {
52
+ initializeCodeMirror();
53
+ initializeIparamsEditors();
54
+ setupEventListeners();
55
+ setupTabNavigation();
56
+ setupModalListeners();
57
+
58
+ // Load available event types and select the first one by default
59
+ loadAvailableEventTypes();
60
+ });
61
+
@@ -0,0 +1,97 @@
1
+ /**
2
+ * API utility functions for making fetch requests
3
+ */
4
+
5
+ import { EMPTY_IPARAM_DEFNS } from './constants.js';
6
+
7
+ /** Fetches available event types from manifest */
8
+ export async function fetchEventTypes() {
9
+ const response = await fetch('/api/manifest');
10
+ if (!response.ok) throw new Error('Failed to load event types');
11
+ return await response.json();
12
+ }
13
+
14
+ /** Fetches test data for a specific event type */
15
+ export async function fetchTestData(eventType) {
16
+ const response = await fetch(`/api/test-data/${eventType}`);
17
+ if (!response.ok) {
18
+ if (response.status === 404) return null;
19
+ const errorData = await response.json();
20
+ throw new Error(errorData.message || 'Failed to load sample data');
21
+ }
22
+ return await response.json();
23
+ }
24
+
25
+ /** Invokes the handler with event data */
26
+ export async function invokeHandler(payload) {
27
+ const response = await fetch('/api/invoke', {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json' },
30
+ body: JSON.stringify(payload)
31
+ });
32
+ const responseText = await response.text();
33
+ if (!response.ok) {
34
+ try {
35
+ return { error: JSON.parse(responseText), status: response.status };
36
+ } catch {
37
+ return { error: { message: `Server returned status ${response.status}` }, status: response.status };
38
+ }
39
+ }
40
+ try {
41
+ return { data: JSON.parse(responseText), status: response.status };
42
+ } catch {
43
+ return { data: { message: 'Success' }, status: response.status };
44
+ }
45
+ }
46
+
47
+ /** Fetches iparams */
48
+ export async function fetchIparams() {
49
+ const response = await fetch('/api/iparams');
50
+ const data = await response.json();
51
+ if (!response.ok) {
52
+ const error = new Error(data.message || data.error || 'Failed to load iparams.json');
53
+ error.rawContent = data.rawContent;
54
+ throw error;
55
+ }
56
+ return data.iparams || EMPTY_IPARAM_DEFNS;
57
+ }
58
+
59
+ /** Saves iparams */
60
+ export async function saveIparams(iparams) {
61
+ const response = await fetch('/api/iparams', {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ iparams })
65
+ });
66
+ if (!response.ok) {
67
+ const errorData = await response.json();
68
+ throw new Error(errorData.message || errorData.error || 'Unknown error');
69
+ }
70
+ return await response.json();
71
+ }
72
+
73
+ /** Fetches iparams inputs */
74
+ export async function fetchIparamsInputs() {
75
+ const response = await fetch('/api/iparams/inputs');
76
+ if (!response.ok) {
77
+ const errorData = await response.json();
78
+ throw new Error(errorData.message || errorData.error || 'Failed to load iparams.local.json');
79
+ }
80
+ const data = await response.json();
81
+ return data.inputs || {};
82
+ }
83
+
84
+ /** Saves iparams inputs (returns body with success or valid: false + missing/message on validation error) */
85
+ export async function saveIparamsInputs(inputs) {
86
+ const response = await fetch('/api/iparams/inputs', {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({ inputs })
90
+ });
91
+ if (!response.ok) {
92
+ const errorData = await response.json();
93
+ throw new Error(errorData.message || errorData.error || 'Unknown error');
94
+ }
95
+ return await response.json();
96
+ }
97
+
@@ -0,0 +1,33 @@
1
+ // Global state variables - using object to allow mutation
2
+ export const EMPTY_IPARAM_DEFNS = {};
3
+
4
+ export const state = {
5
+ editor: null,
6
+ iparamsEditor: null,
7
+ iparamsInputsEditor: null,
8
+ currentEventType: '',
9
+ originalEventData: null,
10
+ originalIparamsData: null,
11
+ originalInputsData: null,
12
+ isIparamsEditing: false,
13
+ isInputsEditing: false,
14
+ isFormView: true, // true for form view, false for JSON view
15
+ iparamDefns: EMPTY_IPARAM_DEFNS // Store iparam definition for form generation
16
+ };
17
+
18
+ // DOM elements
19
+ export const eventTypeSelect = document.getElementById('eventType');
20
+ export const jsonEditor = document.getElementById('jsonEditor');
21
+ export const resetBtn = document.getElementById('resetBtn');
22
+ export const testDataBtn = document.getElementById('testDataBtn');
23
+ export const statusBlock = document.getElementById('statusBlock');
24
+ export const statusText = document.getElementById('statusText');
25
+ export const saveIparamsBtn = document.getElementById('saveIparamsBtn');
26
+ export const saveInputsBtn = document.getElementById('saveInputsBtn');
27
+ export const editIparamsBtn = document.getElementById('editIparamsBtn');
28
+ export const editInputsBtn = document.getElementById('editInputsBtn');
29
+ export const cancelIparamsBtn = document.getElementById('cancelIparamsBtn');
30
+ export const cancelInputsBtn = document.getElementById('cancelInputsBtn');
31
+ export const toggleViewBtn = document.getElementById('toggleViewBtn');
32
+ export const iparamsInputsForm = document.getElementById('iparamsInputsForm');
33
+
@@ -0,0 +1,41 @@
1
+ import { state } from './constants.js';
2
+
3
+ const editorOptions = {
4
+ enableBasicAutocompletion: true,
5
+ enableLiveAutocompletion: true,
6
+ enableSnippets: true,
7
+ fontSize: "13px",
8
+ showPrintMargin: false,
9
+ showGutter: true,
10
+ highlightActiveLine: true,
11
+ wrap: true,
12
+ tabSize: 2,
13
+ useSoftTabs: true,
14
+ showInvisibles: false
15
+ };
16
+
17
+ /** Initializes the Ace code editor with JSON syntax highlighting */
18
+ export function initializeCodeMirror() {
19
+ state.editor = ace.edit("jsonEditor");
20
+ state.editor.setTheme("ace/theme/github");
21
+ state.editor.session.setMode("ace/mode/json");
22
+ state.editor.setOptions(editorOptions);
23
+ state.editor.setValue('{\n \n}');
24
+ const editorEl = document.getElementById('jsonEditor');
25
+ state.editor.on('focus', () => editorEl.classList.add('ace-focused'));
26
+ state.editor.on('blur', () => editorEl.classList.remove('ace-focused'));
27
+ }
28
+
29
+ /** Initializes the Ace code editors for iparams and inputs */
30
+ export function initializeIparamsEditors() {
31
+ state.iparamsEditor = ace.edit("iparamsEditor");
32
+ state.iparamsEditor.setTheme("ace/theme/github");
33
+ state.iparamsEditor.session.setMode("ace/mode/json");
34
+ state.iparamsEditor.setOptions({ ...editorOptions, readOnly: true });
35
+
36
+ state.iparamsInputsEditor = ace.edit("iparamsInputsEditor");
37
+ state.iparamsInputsEditor.setTheme("ace/theme/github");
38
+ state.iparamsInputsEditor.session.setMode("ace/mode/json");
39
+ state.iparamsInputsEditor.setOptions({ ...editorOptions, readOnly: true });
40
+ }
41
+
@@ -0,0 +1,195 @@
1
+ import { state, eventTypeSelect, resetBtn, testDataBtn } from './constants.js';
2
+ import { showStatus, showMissingParamsPopup } from './ui-utils.js';
3
+ import { fetchEventTypes, fetchTestData, invokeHandler } from './api.js';
4
+
5
+ /** Handles changes to the event type selection dropdown */
6
+ export function handleEventTypeChange() {
7
+ state.currentEventType = eventTypeSelect.value;
8
+ if (state.currentEventType) {
9
+ resetBtn.disabled = false;
10
+ testDataBtn.disabled = false;
11
+ loadSampleData();
12
+ } else {
13
+ resetBtn.disabled = true;
14
+ testDataBtn.disabled = true;
15
+ state.editor.setValue('{\n \n}');
16
+ state.originalEventData = null;
17
+ showStatus('Select an event type to get started');
18
+ }
19
+ }
20
+
21
+ /** Loads sample data for the currently selected event type */
22
+ export async function loadSampleData() {
23
+ if (!state.currentEventType || typeof state.currentEventType !== 'string') return;
24
+ try {
25
+ const data = await fetchTestData(state.currentEventType);
26
+ if (data) {
27
+ state.originalEventData = data;
28
+ state.editor.setValue(JSON.stringify(data, null, 2));
29
+ showStatus(`Sample data loaded for ${state.currentEventType}!`);
30
+ } else {
31
+ state.editor.setValue(`{\n // No sample data available for this event\n // Please create a test_data/${state.currentEventType}.json file\n}`);
32
+ showStatus(`No test data found for ${state.currentEventType}. Create test_data/${state.currentEventType}.json`);
33
+ }
34
+ } catch (error) {
35
+ showStatus(`Error loading sample data for ${state.currentEventType}: ${error.message}`);
36
+ }
37
+ }
38
+
39
+ /** Resets the editor content to the original sample data */
40
+ export function resetToOriginalData() {
41
+ if (!state.currentEventType || !state.originalEventData) {
42
+ showStatus('No original data to reset to. Please select an event type first.');
43
+ return;
44
+ }
45
+ state.editor.setValue(JSON.stringify(state.originalEventData, null, 2));
46
+ showStatus('Data reset to original sample data!');
47
+ }
48
+
49
+ /** Tests the current event by sending it to the handler function */
50
+ export async function testEvent() {
51
+ if (!state.currentEventType) return;
52
+ try {
53
+ const jsonData = state.editor.getValue();
54
+ let eventData;
55
+ try {
56
+ eventData = JSON.parse(jsonData);
57
+ } catch (parseError) {
58
+ showStatus('Invalid JSON format. Please check your JSON syntax.');
59
+ return;
60
+ }
61
+ const payload = { event_type: state.currentEventType, ...eventData };
62
+ showStatus('Testing event...');
63
+ testDataBtn.disabled = true;
64
+ const result = await invokeHandler(payload);
65
+
66
+ if (result.status === 200) {
67
+ const eventAnalysis = await fetchEventTypes();
68
+ const currentEvent = eventAnalysis.events?.find(event => event.event_type === state.currentEventType);
69
+ const handlerName = currentEvent?.handler?.name || 'unknown';
70
+ showStatus(`Event handler '${handlerName}' executed successfully! Check the output in the terminal.`);
71
+ } else {
72
+ const errorData = result.error;
73
+ if (result.status === 400) {
74
+ if (errorData.showPopup && errorData.missingParams) {
75
+ showMissingParamsPopup(errorData.missingParams, errorData.message);
76
+ showStatus(`Error: ${errorData.message}`);
77
+ } else if (errorData.message?.includes('Handler function') && errorData.message.includes('not found in handler.js')) {
78
+ const handlerMatch = errorData.message.match(/Handler function '([^']+)'/);
79
+ const handlerName = handlerMatch ? handlerMatch[1] : 'unknown';
80
+ showStatus(`Error: Handler function '${handlerName}' not found in handler.js. Please create this function in your handler.js file.`);
81
+ } else if (errorData.message?.includes('No handler configured')) {
82
+ showStatus(`Error: No handler configured for event type '${state.currentEventType}' in manifest.json.`);
83
+ } else {
84
+ showStatus(`Error: ${errorData.message || 'Bad request'}`);
85
+ }
86
+ } else if (result.status === 404) {
87
+ if (errorData.message?.includes('handler.js not found')) {
88
+ showStatus('Error: handler.js file not found. Please create a handler.js file in your app directory.');
89
+ } else if (errorData.message?.includes('manifest.json not found')) {
90
+ showStatus('Error: manifest.json file not found. Please create a manifest.json file in your app directory.');
91
+ } else {
92
+ showStatus(`Error: ${errorData.message || 'Resource not found'}`);
93
+ }
94
+ } else {
95
+ showStatus(`Error: ${errorData.message || 'Unknown error'}`);
96
+ }
97
+ }
98
+ } catch (error) {
99
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
100
+ showStatus('Error: Cannot connect to server. Please check if the server is running.');
101
+ } else if (error.name === 'SyntaxError') {
102
+ showStatus('Error: Invalid response from server. Please try again.');
103
+ } else {
104
+ showStatus(`Error: ${error.message}`);
105
+ }
106
+ } finally {
107
+ testDataBtn.disabled = false;
108
+ }
109
+ }
110
+
111
+ /** Loads and populates the dropdown with available event types from the server */
112
+ export async function loadAvailableEventTypes() {
113
+ try {
114
+ const data = await fetchEventTypes();
115
+ const events = data.events || [];
116
+ eventTypeSelect.innerHTML = '<option value="">Choose an event type...</option>';
117
+ events.forEach(cbEvent => {
118
+ const option = document.createElement('option');
119
+ option.value = cbEvent.event_type;
120
+ option.textContent = cbEvent.event_type;
121
+ eventTypeSelect.appendChild(option);
122
+ });
123
+
124
+ if (events.length > 0) {
125
+ const incompleteEvents = events.filter(event => event.status === 'incomplete');
126
+ if (incompleteEvents.length > 0) {
127
+ const missingItems = incompleteEvents.map(event => {
128
+ const issues = [];
129
+ if (!event.handler.exists) {
130
+ issues.push(event.handler.name ? `handler function '${event.handler.name}'` : 'handler mapping');
131
+ }
132
+ if (!event.has_test_data) issues.push('test data');
133
+ return `${event.event_type} (missing: ${issues.join(', ')})`;
134
+ });
135
+ showStatus(`Events configured: ${events.length}. Missing components: ${missingItems.join(', ')}`);
136
+ } else {
137
+ showStatus(`All ${events.length} events are properly configured with handlers and test data.`);
138
+ }
139
+ const firstEventType = events[0].event_type;
140
+ eventTypeSelect.value = firstEventType;
141
+ state.currentEventType = firstEventType;
142
+ resetBtn.disabled = false;
143
+ testDataBtn.disabled = false;
144
+ saveToLocalStorage();
145
+ loadSampleData();
146
+ } else {
147
+ showStatus('No events configured in manifest.json. Please add events to your manifest file.');
148
+ }
149
+ } catch (error) {
150
+ showStatus('Error loading event types. Please check your connection.');
151
+ }
152
+ }
153
+
154
+ /** Saves the current editor state to localStorage for persistence */
155
+ export function saveToLocalStorage() {
156
+ if (state.currentEventType) {
157
+ localStorage.setItem('serverless', JSON.stringify({
158
+ eventType: state.currentEventType,
159
+ jsonData: state.editor.getValue()
160
+ }));
161
+ }
162
+ }
163
+
164
+ /** Loads saved editor state from localStorage */
165
+ export function loadFromLocalStorage() {
166
+ const saved = localStorage.getItem('serverless');
167
+ if (saved) {
168
+ try {
169
+ const data = JSON.parse(saved);
170
+ eventTypeSelect.value = data.eventType;
171
+ state.currentEventType = data.eventType;
172
+ state.editor.setValue(data.jsonData);
173
+ resetBtn.disabled = false;
174
+ testDataBtn.disabled = false;
175
+ loadSampleData();
176
+ } catch (error) {
177
+ // Ignore localStorage errors
178
+ }
179
+ }
180
+ }
181
+
182
+ // Keyboard shortcuts
183
+ document.addEventListener('keydown', function(e) {
184
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !testDataBtn.disabled) {
185
+ e.preventDefault();
186
+ testEvent();
187
+ }
188
+ if ((e.ctrlKey || e.metaKey) && e.key === 'r' && !resetBtn.disabled) {
189
+ e.preventDefault();
190
+ resetToOriginalData();
191
+ }
192
+ });
193
+
194
+ window.addEventListener('load', loadFromLocalStorage);
195
+
@@ -0,0 +1,70 @@
1
+ import { state } from './constants.js';
2
+
3
+ /**
4
+ * Returns sections from the stored iparam definition.
5
+ * @returns {Array} Section definitions
6
+ */
7
+ export function getIparamSections() {
8
+ return state.iparamDefns?.installation_parameters?.sections ?? [];
9
+ }
10
+
11
+ /**
12
+ * Builds a unique DOM id for a parameter input within a section.
13
+ * @param {string} sectionName - Section name
14
+ * @param {string} paramName - Parameter name
15
+ * @returns {string} Unique element id
16
+ */
17
+ export function getIparamInputId(sectionName, paramName) {
18
+ return `iparam-${sectionName}-${paramName}`;
19
+ }
20
+
21
+ /**
22
+ * Merges sectioned inputs with defaults from iparam definitions.
23
+ * @param {Object} inputs - Provided input values keyed by section
24
+ * @param {Object} iparamDefns - Iparams definition object
25
+ * @returns {Object} Merged inputs with defaults applied
26
+ */
27
+ export function mergeInputsWithDefaults(inputs, iparamDefns) {
28
+ const merged = {};
29
+ const sections = iparamDefns?.installation_parameters?.sections ?? [];
30
+
31
+ sections.forEach(section => {
32
+ const sectionInputs = inputs?.[section.name] ?? {};
33
+ const mergedSection = {};
34
+
35
+ section.parameters.forEach(param => {
36
+ const value = sectionInputs[param.name];
37
+ const hasValue = value !== undefined && value !== null && value !== '';
38
+ if (hasValue && !(Array.isArray(value) && value.length === 0)) {
39
+ mergedSection[param.name] = value;
40
+ }
41
+ if (mergedSection[param.name] === undefined && param.default !== undefined && param.default !== null) {
42
+ mergedSection[param.name] = param.default;
43
+ }
44
+ });
45
+
46
+ if (Object.keys(mergedSection).length > 0) {
47
+ merged[section.name] = mergedSection;
48
+ }
49
+ });
50
+
51
+ return merged;
52
+ }
53
+
54
+ /** Updates the multiselect dropdown button display */
55
+ export function updateMultiselectDisplay(button, panel, paramName) {
56
+ const selected = Array.from(panel.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value);
57
+ const isOpen = button.classList.contains('open');
58
+ button.innerHTML = '';
59
+ if (selected.length > 0) {
60
+ button.textContent = selected.join(', ');
61
+ button.classList.remove('placeholder');
62
+ } else {
63
+ button.textContent = '-- Select options --';
64
+ button.classList.add('placeholder');
65
+ }
66
+ const arrow = document.createElement('span');
67
+ arrow.className = 'select-dropdown-arrow';
68
+ button.appendChild(arrow);
69
+ if (isOpen) button.classList.add('open');
70
+ }