@widergy/mobile-ui 2.0.2 → 3.4.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.
@@ -0,0 +1,373 @@
1
+ /* eslint-disable import/no-unresolved */
2
+ // FileDownloadManager - Core file operations module
3
+ // Handles file generation, storage, notifications, and opening across platforms
4
+
5
+ import { File, Paths } from 'expo-file-system';
6
+ import * as FileSystemLegacy from 'expo-file-system/legacy';
7
+ import * as Notifications from 'expo-notifications';
8
+ import * as Sharing from 'expo-sharing';
9
+ import { Platform, InteractionManager } from 'react-native';
10
+ import { decode as atob, encode as btoa } from 'base-64';
11
+ import XLSX from 'xlsx';
12
+ import dayjs from 'dayjs';
13
+ import * as FileViewer from 'react-native-file-viewer';
14
+
15
+ import { FILE_TYPES, FILE_MIME_TYPES, VIEWER_SUPPORT, DEFAULT_TRANSLATIONS, replaceParams } from './types';
16
+
17
+ const DOWNLOAD_NOTIFICATION_CHANNEL_ID = 'file-downloads';
18
+
19
+ let notificationResponseListenerRegistered = false;
20
+ let androidNotificationChannelConfigured = false;
21
+ let cachedNotificationsPermission = null;
22
+
23
+ // ==================== Utilities ====================
24
+
25
+ const slugify = name =>
26
+ (name || 'document')
27
+ .toString()
28
+ .trim()
29
+ .replace(/[\s]+/g, '_')
30
+ .replace(/[^\w.-]+/g, '');
31
+
32
+ const buildTimestampSuffix = () => dayjs().format('DD_MM_YYYY_HH_mm_ss');
33
+
34
+ const appendTimestampToName = fileName => {
35
+ const lastDot = fileName.lastIndexOf('.');
36
+ const suffix = buildTimestampSuffix();
37
+
38
+ if (lastDot === -1) {
39
+ return `${fileName}_${suffix}`;
40
+ }
41
+
42
+ const base = fileName.substring(0, lastDot);
43
+ const extension = fileName.substring(lastDot);
44
+ return `${base}_${suffix}${extension}`;
45
+ };
46
+
47
+ const base64ToUint8Array = base64 => {
48
+ const binary = atob(base64);
49
+ const bytes = new Uint8Array(binary.length);
50
+ for (let i = 0; i < binary.length; i += 1) {
51
+ bytes[i] = binary.charCodeAt(i);
52
+ }
53
+ return bytes;
54
+ };
55
+
56
+ const uint8ArrayToBase64 = bytes => {
57
+ if (!bytes) return '';
58
+ let binary = '';
59
+ const chunk = 0x8000;
60
+ for (let i = 0; i < bytes.length; i += chunk) {
61
+ const slice = bytes.subarray(i, i + chunk);
62
+ binary += String.fromCharCode.apply(null, Array.from(slice));
63
+ }
64
+ return btoa(binary);
65
+ };
66
+
67
+ const ensureContentUri = async uri => {
68
+ if (!uri) return null;
69
+ if (uri.startsWith('content://')) return uri;
70
+
71
+ const getContentUriAsync =
72
+ Platform.OS === 'android' && typeof FileSystemLegacy.getContentUriAsync === 'function'
73
+ ? FileSystemLegacy.getContentUriAsync
74
+ : null;
75
+
76
+ if (!getContentUriAsync) {
77
+ throw new Error('Expo FileSystem legacy no disponible para obtener un content:// URI.');
78
+ }
79
+ return getContentUriAsync(uri);
80
+ };
81
+
82
+ // ==================== File Generation ====================
83
+
84
+ const generateExcelFile = data => {
85
+ const ws = XLSX.utils.json_to_sheet(data);
86
+ const wb = XLSX.utils.book_new();
87
+ XLSX.utils.book_append_sheet(wb, ws, 'Datos');
88
+ return XLSX.write(wb, { type: 'base64', bookType: 'xlsx' });
89
+ };
90
+
91
+ const generateJsonFile = data => {
92
+ const jsonString = JSON.stringify(data, null, 2);
93
+ return btoa(jsonString);
94
+ };
95
+
96
+ const generateFileContent = (type, data) => {
97
+ switch (type) {
98
+ case FILE_TYPES.XLS:
99
+ return generateExcelFile(data);
100
+ case FILE_TYPES.JSON:
101
+ return generateJsonFile(data);
102
+ case FILE_TYPES.PDF:
103
+ // Para PDF esperamos que data ya sea base64
104
+ return data;
105
+ default:
106
+ throw new Error(`Unsupported file type: ${type}`);
107
+ }
108
+ };
109
+
110
+ // ==================== File Storage ====================
111
+
112
+ const saveToInternalDocuments = async (base64Data, fullFileName) => {
113
+ if (Platform.OS === 'ios') {
114
+ const base64Payload = typeof base64Data === 'string' ? base64Data : uint8ArrayToBase64(base64Data);
115
+
116
+ let targetName = fullFileName;
117
+ let targetUri = `${FileSystemLegacy.documentDirectory}${targetName}`;
118
+ const existingInfo = await FileSystemLegacy.getInfoAsync(targetUri);
119
+
120
+ if (existingInfo.exists) {
121
+ targetName = appendTimestampToName(fullFileName);
122
+ targetUri = `${FileSystemLegacy.documentDirectory}${targetName}`;
123
+ }
124
+
125
+ await FileSystemLegacy.writeAsStringAsync(targetUri, base64Payload, {
126
+ encoding: FileSystemLegacy.EncodingType.Base64
127
+ });
128
+
129
+ const info = await FileSystemLegacy.getInfoAsync(targetUri);
130
+ if (!info?.exists || !info.uri) throw new Error('write failed');
131
+
132
+ return { fileName: targetName, fileUri: info.uri };
133
+ }
134
+
135
+ let targetName = fullFileName;
136
+ let file = new File(Paths.document, targetName);
137
+ const existingInfo = await file.info();
138
+
139
+ if (existingInfo.exists) {
140
+ targetName = appendTimestampToName(fullFileName);
141
+ file = new File(Paths.document, targetName);
142
+ }
143
+
144
+ await file.parentDirectory.create({ intermediates: true, idempotent: true });
145
+ await file.create({ overwrite: true });
146
+ const bytes = base64Data instanceof Uint8Array ? base64Data : base64ToUint8Array(base64Data);
147
+ await file.write(bytes);
148
+
149
+ const info = await file.info();
150
+ if (!info?.exists || !info.uri) throw new Error('write failed');
151
+
152
+ return { fileName: targetName, fileUri: info.uri };
153
+ };
154
+
155
+ // ==================== File Opening ====================
156
+
157
+ const openFileWithNativeViewer = async uri => {
158
+ const viewerTarget = uri.startsWith('file://') ? uri.replace('file://', '') : uri;
159
+
160
+ try {
161
+ await FileViewer.open(viewerTarget, {
162
+ showOpenWithDialog: Platform.OS === 'android'
163
+ });
164
+ return true;
165
+ } catch (error) {
166
+ // eslint-disable-next-line no-console
167
+ console.log('Native viewer error:', error);
168
+ return false;
169
+ }
170
+ };
171
+
172
+ const shareFile = async (uri, fileName, fileType, translations) => {
173
+ try {
174
+ const isAvailable = await Sharing.isAvailableAsync();
175
+ if (!isAvailable) {
176
+ throw new Error('Sharing is not available on this device');
177
+ }
178
+
179
+ // En Android necesitamos content URI
180
+ const shareUri = Platform.OS === 'android' ? await ensureContentUri(uri) : uri;
181
+
182
+ await Sharing.shareAsync(shareUri, {
183
+ mimeType: FILE_MIME_TYPES[fileType],
184
+ dialogTitle: replaceParams(translations.shareFileTitle, { fileName }),
185
+ UTI: FILE_MIME_TYPES[fileType]
186
+ });
187
+ return true;
188
+ } catch (error) {
189
+ // eslint-disable-next-line no-console
190
+ console.log('Share error:', error);
191
+ throw new Error(translations.shareError);
192
+ }
193
+ };
194
+
195
+ const openOrShareFile = async (uri, fileName, fileType, translations) => {
196
+ if (!uri) throw new Error('No se pudo obtener la ubicación del archivo');
197
+
198
+ const supportsNativeViewer = VIEWER_SUPPORT[fileType];
199
+
200
+ // Intentar abrir con visor nativo si está soportado
201
+ if (supportsNativeViewer) {
202
+ const opened = await openFileWithNativeViewer(uri, fileType);
203
+ if (opened) return;
204
+ }
205
+
206
+ // Si no se pudo abrir nativamente o no está soportado, compartir
207
+ await shareFile(uri, fileName, fileType, translations);
208
+ };
209
+
210
+ // ==================== Notifications ====================
211
+
212
+ const ensureNotificationsPermissionAsync = async () => {
213
+ if (cachedNotificationsPermission === true) return true;
214
+ if (cachedNotificationsPermission === false) return false;
215
+
216
+ try {
217
+ let permissions = await Notifications.getPermissionsAsync();
218
+ if (!permissions.granted) {
219
+ permissions = await Notifications.requestPermissionsAsync();
220
+ }
221
+ cachedNotificationsPermission = Boolean(permissions.granted);
222
+ return cachedNotificationsPermission;
223
+ } catch (permissionError) {
224
+ // eslint-disable-next-line no-console
225
+ console.log('notifications permission error:', permissionError);
226
+ cachedNotificationsPermission = false;
227
+ return false;
228
+ }
229
+ };
230
+
231
+ const ensureAndroidNotificationChannelAsync = async () => {
232
+ if (Platform.OS !== 'android' || androidNotificationChannelConfigured) return;
233
+
234
+ try {
235
+ await Notifications.setNotificationChannelAsync(DOWNLOAD_NOTIFICATION_CHANNEL_ID, {
236
+ name: 'File Downloads',
237
+ importance: Notifications.AndroidImportance.DEFAULT
238
+ });
239
+ androidNotificationChannelConfigured = true;
240
+ } catch (channelError) {
241
+ // eslint-disable-next-line no-console
242
+ console.log('notification channel error:', channelError);
243
+ }
244
+ };
245
+
246
+ const ensureNotificationListener = translations => {
247
+ if (notificationResponseListenerRegistered) return;
248
+
249
+ Notifications.addNotificationResponseReceivedListener(response => {
250
+ const { fileUri, fileName, fileType } = response.notification.request.content.data || {};
251
+ if (!fileUri) return;
252
+
253
+ InteractionManager.runAfterInteractions(() => {
254
+ openOrShareFile(fileUri, fileName, fileType, translations).catch(openError => {
255
+ // eslint-disable-next-line no-console
256
+ console.log('notification response open error:', openError);
257
+ });
258
+ });
259
+ });
260
+
261
+ notificationResponseListenerRegistered = true;
262
+ };
263
+
264
+ const notifyDownloadSuccess = async (fileName, fileUri, fileType, translations) => {
265
+ if (!fileUri) return;
266
+
267
+ try {
268
+ ensureNotificationListener(translations);
269
+ const hasPermission = await ensureNotificationsPermissionAsync();
270
+ if (!hasPermission) return;
271
+
272
+ if (Platform.OS === 'android') {
273
+ await ensureAndroidNotificationChannelAsync();
274
+ }
275
+
276
+ await Notifications.scheduleNotificationAsync({
277
+ content: {
278
+ title: translations.downloadReadyTitle,
279
+ body: replaceParams(translations.downloadReadyBody, { fileName }),
280
+ data: { fileUri, fileName, fileType },
281
+ ...(Platform.OS === 'android' ? { channelId: DOWNLOAD_NOTIFICATION_CHANNEL_ID } : {})
282
+ },
283
+ trigger: null
284
+ });
285
+ } catch (notificationError) {
286
+ // eslint-disable-next-line no-console
287
+ console.log('notifyDownloadSuccess error:', notificationError);
288
+ }
289
+ };
290
+
291
+ // ==================== Public API ====================
292
+
293
+ /**
294
+ * Main function to handle file download workflow
295
+ * @param {FileGenerationConfig} config - File generation configuration
296
+ * @returns {Promise<{success: boolean, fileUri: string, fileName: string}>}
297
+ */
298
+ export const downloadFile = async config => {
299
+ const { type, fileName, data, dispatch, translations: customTranslations, options = {} } = config;
300
+
301
+ // Merge custom translations with defaults
302
+ const translations = { ...DEFAULT_TRANSLATIONS, ...customTranslations };
303
+
304
+ try {
305
+ // 1. Generate file content
306
+ const base64Content = generateFileContent(type, data);
307
+
308
+ // 2. Save to internal storage
309
+ const safeName = `${slugify(fileName)}.${type}`;
310
+ const { fileUri, fileName: storedFileName } = await saveToInternalDocuments(base64Content, safeName);
311
+
312
+ // 3. Show success notification via dispatch (if provided)
313
+ if (dispatch && !options.hideNotification && options.showSnackbar) {
314
+ options.showSnackbar(dispatch, 'success');
315
+ }
316
+
317
+ // 4. Send notification
318
+ if (!options.hideNotification) {
319
+ await notifyDownloadSuccess(storedFileName, fileUri, type, translations);
320
+ }
321
+
322
+ // 5. Auto-open if requested
323
+ if (!options.preventAutoOpen) {
324
+ await openOrShareFile(fileUri, storedFileName, type, translations);
325
+ }
326
+
327
+ // 6. Success callback
328
+ if (options.successCallback) {
329
+ options.successCallback({ fileUri, fileName: storedFileName });
330
+ }
331
+
332
+ return {
333
+ success: true,
334
+ fileUri,
335
+ fileName: storedFileName
336
+ };
337
+ } catch (error) {
338
+ // eslint-disable-next-line no-console
339
+ console.log('downloadFile error:', error);
340
+
341
+ // Error callback
342
+ if (options.failureCallback) {
343
+ options.failureCallback(error);
344
+ }
345
+
346
+ // Show error via dispatch
347
+ if (dispatch && options.showSnackbar) {
348
+ options.showSnackbar(dispatch, 'error', error);
349
+ }
350
+
351
+ throw error;
352
+ }
353
+ };
354
+
355
+ /**
356
+ * Open a previously saved file
357
+ * @param {string} fileUri - File URI
358
+ * @param {string} fileName - File name
359
+ * @param {string} fileType - File type
360
+ * @param {Translations} customTranslations - Custom translations (optional)
361
+ */
362
+ export const openFile = async (fileUri, fileName, fileType, customTranslations = {}) => {
363
+ const translations = { ...DEFAULT_TRANSLATIONS, ...customTranslations };
364
+ await openOrShareFile(fileUri, fileName, fileType, translations);
365
+ };
366
+
367
+ export default {
368
+ downloadFile,
369
+ openFile,
370
+ FILE_TYPES,
371
+ FILE_MIME_TYPES,
372
+ VIEWER_SUPPORT
373
+ };
@@ -0,0 +1,81 @@
1
+ // FileDownloadManager types and constants
2
+
3
+ export const FILE_TYPES = {
4
+ PDF: 'pdf',
5
+ XLS: 'xlsx',
6
+ JSON: 'json'
7
+ };
8
+
9
+ export const FILE_MIME_TYPES = {
10
+ [FILE_TYPES.PDF]: 'application/pdf',
11
+ [FILE_TYPES.XLS]: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
12
+ [FILE_TYPES.JSON]: 'application/json'
13
+ };
14
+
15
+ export const VIEWER_SUPPORT = {
16
+ [FILE_TYPES.PDF]: true, // Native PDF viewer
17
+ [FILE_TYPES.XLS]: false, // Share only
18
+ [FILE_TYPES.JSON]: false // Share only
19
+ };
20
+
21
+ /**
22
+ * @typedef {Object} Translations
23
+ * @property {string} downloadReadyTitle - Título de la notificación cuando el archivo está listo
24
+ * Ejemplo: "Archivo listo" o "File ready"
25
+ *
26
+ * @property {string} downloadReadyBody - Cuerpo de la notificación con el nombre del archivo
27
+ * Parámetros: {fileName}
28
+ * Ejemplo: "{fileName} está disponible" o "{fileName} is available"
29
+ *
30
+ * @property {string} shareFileTitle - Título del diálogo de compartir archivo
31
+ * Parámetros: {fileName}
32
+ * Ejemplo: "Compartir {fileName}" o "Share {fileName}"
33
+ *
34
+ * @property {string} shareError - Mensaje de error cuando falla el compartir
35
+ * Ejemplo: "No se pudo compartir el archivo" o "Could not share file"
36
+ *
37
+ * @property {string} successDownload - Mensaje de éxito para snackbar/toast
38
+ * Ejemplo: "Archivo descargado correctamente" o "File downloaded successfully"
39
+ *
40
+ * @property {string} errorDownload - Mensaje de error para snackbar/toast
41
+ * Ejemplo: "Error al descargar el archivo" o "Error downloading file"
42
+ *//**
43
+ * Traducciones por defecto en español
44
+ */
45
+ export const DEFAULT_TRANSLATIONS = {
46
+ downloadReadyTitle: 'Archivo listo',
47
+ downloadReadyBody: '{fileName} está disponible',
48
+ shareFileTitle: 'Compartir {fileName}',
49
+ shareError: 'No se pudo compartir el archivo',
50
+ successDownload: 'Archivo descargado correctamente',
51
+ errorDownload: 'Error al descargar el archivo'
52
+ };
53
+
54
+ /**
55
+ * Reemplaza parámetros en un string de traducción
56
+ * @param {string} template - Template con parámetros entre llaves
57
+ * @param {Object} params - Objeto con los valores a reemplazar
58
+ * @returns {string} String con parámetros reemplazados
59
+ */
60
+ export const replaceParams = (template, params = {}) => {
61
+ if (!template) return '';
62
+ return Object.keys(params).reduce(
63
+ (result, key) => result.replace(new RegExp(`\\{${key}\\}`, 'g'), params[key]),
64
+ template
65
+ );
66
+ };
67
+
68
+ /**
69
+ * @typedef {Object} FileGenerationConfig
70
+ * @property {string} type - File type: 'pdf', 'xlsx', 'json'
71
+ * @property {string} fileName - Base name without extension
72
+ * @property {any} data - File data (Excel data array, JSON object, or base64 for PDF)
73
+ * @property {Function} [dispatch] - Redux dispatch function for notifications
74
+ * @property {Translations} [translations] - Custom translations (falls back to DEFAULT_TRANSLATIONS)
75
+ * @property {Object} [options] - Additional options
76
+ * @property {boolean} [options.hideNotification] - Skip notification
77
+ * @property {boolean} [options.preventAutoOpen] - Don't auto-open after save
78
+ * @property {Function} [options.successCallback] - Called after successful save
79
+ * @property {Function} [options.failureCallback] - Called on error
80
+ * @property {Function} [options.showSnackbar] - Custom snackbar function
81
+ */
@@ -31,6 +31,13 @@ class FilePicker extends Component {
31
31
  allowedPDFUploadSizes,
32
32
  pdfFormatError
33
33
  } = this.props;
34
+
35
+ const getRealFileType = mimeType => {
36
+ if (!mimeType || typeof mimeType !== 'string') return null;
37
+ const parts = mimeType.split('/');
38
+ return parts[1]?.toLowerCase() || null;
39
+ };
40
+
34
41
  try {
35
42
  const normalizeTypes = types => {
36
43
  if (!types || types.length === 0) return DEFAULT_ALLOWED_TYPES;
@@ -71,7 +78,8 @@ class FilePicker extends Component {
71
78
  onMaxSizeError(document.size, maxFileByteSize);
72
79
  return;
73
80
  }
74
- const file = !avoidRetrieveFile && (await retrieveFile(document.uri, document.type));
81
+ const realFileType = getRealFileType(document.type);
82
+ const file = !avoidRetrieveFile && (await retrieveFile(document.uri, realFileType));
75
83
 
76
84
  if (file && isPDF && !isEmpty(allowedPDFUploadSizes)) {
77
85
  const isWrongFormat = await pdfAspectRatioValidation(file, allowedPDFUploadSizes);
@@ -85,7 +93,7 @@ class FilePicker extends Component {
85
93
  }
86
94
  }
87
95
  if (onChange) {
88
- onChange(avoidRetrieveFile ? { document } : { file: blobToFile(file, document.type) });
96
+ onChange(avoidRetrieveFile ? { document } : { file: blobToFile(file, realFileType) });
89
97
  }
90
98
  this.setState({ fileName: document.name });
91
99
  } catch (err) {
@@ -57,7 +57,14 @@ const ImagePickerComponent = ({
57
57
  onError('No se pudo obtener el archivo');
58
58
  return;
59
59
  }
60
- const file = !avoidRetrieveFile && (await retrieveFile(item.uri, item.type));
60
+ const getRealFileType = mimeType => {
61
+ if (!mimeType || typeof mimeType !== 'string') return null;
62
+ const parts = mimeType.split('/');
63
+ return parts[1]?.toLowerCase() || null;
64
+ };
65
+ const realFileType = getRealFileType(item.type);
66
+
67
+ const file = !avoidRetrieveFile && (await retrieveFile(item.uri, realFileType));
61
68
  if (!avoidRetrieveFile && !file) {
62
69
  onError(response.errorCode);
63
70
  return;
@@ -79,6 +79,12 @@ const MultipleFilePicker = ({
79
79
 
80
80
  const remainingSlots = () => Math.max((maxFiles || 0) - uploadedFiles.length, 0);
81
81
 
82
+ const getRealFileType = mimeType => {
83
+ if (!mimeType || typeof mimeType !== 'string') return null;
84
+ const parts = mimeType.split('/');
85
+ return parts[1]?.toLowerCase() || null;
86
+ };
87
+
82
88
  const handleAssets = async response => {
83
89
  if (!response || response.didCancel) {
84
90
  return;
@@ -89,7 +95,8 @@ const MultipleFilePicker = ({
89
95
  }
90
96
  closeDrawer();
91
97
  response.assets.forEach(async asset => {
92
- const file = await retrieveFile(asset.uri, asset.type);
98
+ const realFileType = getRealFileType(asset.mimeType);
99
+ const file = await retrieveFile(asset.uri, realFileType);
93
100
  if (!file) {
94
101
  onError(response.errorCode || 'No se pudo obtener el archivo');
95
102
  return;
@@ -98,7 +105,7 @@ const MultipleFilePicker = ({
98
105
  if (isFileSizeInvaid({ size: file.size }, maxFileByteSize, onMaxSizeError)) return;
99
106
  setNewFile({
100
107
  uploadFile: { name: file.data.name, size: file.data.size },
101
- rawFile: blobToFile(file, asset.type)
108
+ rawFile: blobToFile(file, realFileType)
102
109
  });
103
110
  });
104
111
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@widergy/mobile-ui",
3
3
  "description": "Widergy Mobile Components",
4
4
  "author": "widergy",
5
- "version": "2.0.2",
5
+ "version": "3.4.1",
6
6
  "repository": "https://github.com/widergy/mobile-ui.git",
7
7
  "main": "lib/index.js",
8
8
  "files": [
@@ -37,23 +37,30 @@
37
37
  "@react-navigation/native": "^6.1.9",
38
38
  "@tabler/icons-react-native": "^3.34.1",
39
39
  "@widergy/web-utils": "^2.0.0",
40
+ "base-64": "^1.0.0",
40
41
  "core-js": "3",
42
+ "dayjs": "^1.11.18",
41
43
  "deprecated-react-native-prop-types": "^5.0.0",
42
44
  "expo-document-picker": "^13.1.6",
45
+ "expo-file-system": "^19.0.17",
43
46
  "expo-image-manipulator": "^13.1.7",
44
47
  "expo-image-picker": "^16.1.4",
48
+ "expo-notifications": "^0.32.12",
49
+ "expo-sharing": "^14.0.7",
45
50
  "invariant": "^2.2.4",
46
51
  "lodash": "^4.17.21",
47
52
  "numeral": "^2.0.6",
48
53
  "pdf-lib": "^1.17.1",
49
54
  "react-native-collapsible": "^1.6.1",
55
+ "react-native-file-viewer": "^2.1.5",
50
56
  "react-native-linear-gradient": "^2.8.3",
51
57
  "react-native-markdown-display": "^7.0.0-alpha.2",
52
58
  "react-native-modal": "^13.0.2",
53
59
  "react-native-pager-view": "^6.8.0",
54
60
  "react-native-safe-area-context": "^5.2.0",
55
61
  "react-native-svg": "^15.11.2",
56
- "react-native-uuid": "^2.0.3"
62
+ "react-native-uuid": "^2.0.3",
63
+ "xlsx": "^0.18.5"
57
64
  },
58
65
  "devDependencies": {
59
66
  "@babel/cli": "^7.22.10",