contentoh-components-library 21.6.50 → 21.6.52

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 (90) hide show
  1. package/.env.test +0 -1
  2. package/dist/components/atoms/IconFile/index.js +2 -85
  3. package/dist/components/atoms/IconFile/styles.js +1 -1
  4. package/dist/components/atoms/ProductImage/ProductImage.stories.js +2 -70
  5. package/dist/components/atoms/ProductImage/index.js +27 -100
  6. package/dist/components/atoms/ProductImage/styles.js +1 -3
  7. package/dist/components/molecules/EditionActiveImage/index.js +2 -4
  8. package/dist/components/molecules/GalleryElement/index.js +4 -13
  9. package/dist/components/molecules/GalleryHeader/index.js +24 -96
  10. package/dist/components/molecules/GalleryHeader/styles.js +1 -4
  11. package/dist/components/molecules/ImageSelector/index.js +0 -1
  12. package/dist/components/organisms/ImagePreviewer/index.js +0 -1
  13. package/dist/components/organisms/ProductImageModal/index.js +2 -3
  14. package/dist/components/organisms/PropagateChangesModal/index.js +6 -1
  15. package/dist/components/pages/ProviderProductEdition/context/provider-product-edition.context.js +10 -8
  16. package/dist/components/pages/ProviderProductEdition/index.js +189 -72
  17. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.context.js +25 -338
  18. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +2 -12
  19. package/dist/components/pages/RetailerProductEdition/index.js +579 -631
  20. package/dist/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +0 -1
  21. package/dist/components/pages/RetailerProductEdition/stories/TextEditor.stories.js +145 -45
  22. package/dist/global-files/data.js +3 -16
  23. package/dist/index.js +90 -142
  24. package/package.json +1 -1
  25. package/src/components/atoms/IconFile/index.js +1 -28
  26. package/src/components/atoms/IconFile/styles.js +0 -8
  27. package/src/components/atoms/ProductImage/ProductImage.stories.js +0 -38
  28. package/src/components/atoms/ProductImage/index.js +20 -95
  29. package/src/components/atoms/ProductImage/styles.js +7 -92
  30. package/src/components/molecules/EditionActiveImage/index.js +2 -2
  31. package/src/components/molecules/GalleryElement/index.js +0 -9
  32. package/src/components/molecules/GalleryHeader/index.js +13 -65
  33. package/src/components/molecules/GalleryHeader/styles.js +1 -2
  34. package/src/components/molecules/ImageSelector/index.js +0 -1
  35. package/src/components/organisms/ImagePreviewer/index.js +0 -1
  36. package/src/components/organisms/ProductImageModal/index.js +0 -1
  37. package/src/components/organisms/PropagateChangesModal/index.js +10 -3
  38. package/src/components/pages/ProviderProductEdition/context/provider-product-edition.context.jsx +2 -1
  39. package/src/components/pages/ProviderProductEdition/index.js +111 -6
  40. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.context.jsx +2 -202
  41. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +0 -12
  42. package/src/components/pages/RetailerProductEdition/index.js +258 -363
  43. package/src/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +0 -1
  44. package/src/components/pages/RetailerProductEdition/stories/TextEditor.stories.js +104 -51
  45. package/src/global-files/data.js +2 -17
  46. package/src/index.js +0 -4
  47. package/.claude/launch.json +0 -14
  48. package/dist/ai/prompts/attribute.prompt.js +0 -28
  49. package/dist/ai/prompts/description.prompt.js +0 -33
  50. package/dist/ai/schemas/attribute.schema.js +0 -35
  51. package/dist/ai/schemas/description.schema.js +0 -27
  52. package/dist/components/atoms/AssetPreview/AssetPreview.stories.js +0 -118
  53. package/dist/components/atoms/AssetPreview/index.js +0 -150
  54. package/dist/components/atoms/AssetPreview/styles.js +0 -28
  55. package/dist/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +0 -147
  56. package/dist/components/molecules/GalleryAssetElement/index.js +0 -178
  57. package/dist/components/molecules/GalleryAssetElement/styles.js +0 -26
  58. package/dist/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +0 -56
  59. package/dist/components/molecules/GalleryTrayTabs/index.js +0 -60
  60. package/dist/components/molecules/GalleryTrayTabs/styles.js +0 -20
  61. package/dist/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +0 -171
  62. package/dist/components/organisms/DigitalAssetsTray/index.js +0 -211
  63. package/dist/components/organisms/DigitalAssetsTray/styles.js +0 -22
  64. package/dist/global-files/digital-assets/assetTypes.js +0 -480
  65. package/dist/global-files/digital-assets/assets.actions.js +0 -64
  66. package/dist/global-files/digital-assets/assets.adapter.js +0 -304
  67. package/dist/global-files/digital-assets/assets.mock.js +0 -136
  68. package/dist/global-files/digital-assets/assets.reducer.js +0 -220
  69. package/dist/global-files/digital-assets/uploadAssets.js +0 -179
  70. package/dist/global-files/digital-assets/useDigitalAssets.js +0 -522
  71. package/docs/activos-digitales.md +0 -273
  72. package/src/components/atoms/AssetPreview/AssetPreview.stories.js +0 -78
  73. package/src/components/atoms/AssetPreview/index.js +0 -119
  74. package/src/components/atoms/AssetPreview/styles.js +0 -190
  75. package/src/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +0 -102
  76. package/src/components/molecules/GalleryAssetElement/index.js +0 -162
  77. package/src/components/molecules/GalleryAssetElement/styles.js +0 -159
  78. package/src/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +0 -32
  79. package/src/components/molecules/GalleryTrayTabs/index.js +0 -41
  80. package/src/components/molecules/GalleryTrayTabs/styles.js +0 -52
  81. package/src/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +0 -101
  82. package/src/components/organisms/DigitalAssetsTray/index.js +0 -189
  83. package/src/components/organisms/DigitalAssetsTray/styles.js +0 -183
  84. package/src/global-files/digital-assets/assetTypes.js +0 -419
  85. package/src/global-files/digital-assets/assets.actions.js +0 -50
  86. package/src/global-files/digital-assets/assets.adapter.js +0 -229
  87. package/src/global-files/digital-assets/assets.mock.js +0 -122
  88. package/src/global-files/digital-assets/assets.reducer.js +0 -197
  89. package/src/global-files/digital-assets/uploadAssets.js +0 -109
  90. package/src/global-files/digital-assets/useDigitalAssets.js +0 -329
@@ -1,419 +0,0 @@
1
- /**
2
- * Catálogo de activos digitales.
3
- *
4
- * Este archivo es la ÚNICA fuente de verdad de qué se puede subir a la bandeja
5
- * y con qué límites. Ajustar un tipo o un tope es cambiar un dato aquí, no
6
- * tocar lógica repartida por la pantalla.
7
- *
8
- * Un activo NO es un "tipo de toma". Las imágenes de producto se validan contra
9
- * `image_manager` (orden, obligatoriedad, cupo por cadena); los activos no
10
- * entran a ese cruce y por eso tienen su propio catálogo.
11
- *
12
- * DOS COSAS QUE PARECEN DETALLE Y NO LO SON:
13
- *
14
- * 1. El tipo se decide por EXTENSIÓN, no por mime. El navegador no sabe qué es
15
- * un .psd, un .ai o un .indd: reporta `application/octet-stream`, cadena
16
- * vacía, o —peor— `application/pdf` para un .ai. Validar por mime dejaría
17
- * fuera justo los formatos editables que se pidieron.
18
- * 2. Por lo mismo, el `accept` del dropzone se arma con extensiones además de
19
- * mimes. Si solo llevara mimes, react-dropzone descartaría esos archivos
20
- * antes de que nuestra validación llegue a verlos, y el usuario no vería
21
- * ningún mensaje: simplemente no pasaría nada al soltarlos.
22
- */
23
-
24
- const KB = 1024;
25
- const MB = 1024 * KB;
26
-
27
- /**
28
- * Cómo se pinta un archivo en pantalla. No es lo mismo que el tipo de activo:
29
- * un 360 puede ser mp4 (se reproduce) o gif (se muestra), así que la forma de
30
- * previsualizar se resuelve por el archivo real, no por su categoría.
31
- */
32
- export const ASSET_PREVIEW = {
33
- VIDEO: "video",
34
- IMAGE: "image",
35
- ICON: "icon",
36
- };
37
-
38
- /**
39
- * PROVISIONAL: cantidad máxima de activos por producto. Es el único dato que
40
- * el manager no respondió. Subido de 10 a 20 porque la lista de tipos creció
41
- * (documentos y editables, no solo multimedia).
42
- */
43
- export const MAX_ASSETS_PER_PRODUCT = 20;
44
-
45
- /**
46
- * Duración válida de un video.
47
- *
48
- * Hoy la ficha valida `duracion <= 10 || duracion >= 600` mientras el mensaje al
49
- * usuario dice "entre 15 segundos y 10 minutos". Los dos no pueden ser ciertos.
50
- * Se conserva el rango que el código valida de verdad, y el mensaje se genera
51
- * desde estos mismos números para que no se vuelvan a desincronizar.
52
- */
53
- export const VIDEO_DURATION_SECONDS = {
54
- min: 10,
55
- max: 600,
56
- };
57
-
58
- export const ASSET_TYPES = {
59
- 360: {
60
- id: "360",
61
- label: "360",
62
- description: "Giro del producto, en mp4 o gif animado",
63
- mimes: ["video/mp4", "image/gif"],
64
- extensions: ["mp4", "gif"],
65
- maxBytes: 200 * MB,
66
- validatesDuration: true,
67
- },
68
- video: {
69
- id: "video",
70
- label: "Video",
71
- description: "Video de producto o promocional",
72
- mimes: ["video/mp4", "video/quicktime", "video/webm"],
73
- extensions: ["mp4", "mov", "webm"],
74
- maxBytes: 200 * MB,
75
- validatesDuration: true,
76
- },
77
- gif: {
78
- id: "gif",
79
- label: "GIF",
80
- description: "Animación corta",
81
- mimes: ["image/gif"],
82
- extensions: ["gif"],
83
- maxBytes: 20 * MB,
84
- validatesDuration: false,
85
- },
86
- documento: {
87
- id: "documento",
88
- label: "Documento",
89
- description: "Manual, ficha técnica, certificado",
90
- mimes: [
91
- "application/pdf",
92
- "application/msword",
93
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
94
- ],
95
- extensions: ["pdf", "doc", "docx"],
96
- maxBytes: 50 * MB,
97
- validatesDuration: false,
98
- },
99
- hoja_calculo: {
100
- id: "hoja_calculo",
101
- label: "Hoja de cálculo",
102
- description: "Listas de medidas, tablas de tallas, matrices",
103
- mimes: [
104
- "application/vnd.ms-excel",
105
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
106
- "text/csv",
107
- ],
108
- extensions: ["xlsx", "xls", "csv"],
109
- maxBytes: 20 * MB,
110
- validatesDuration: false,
111
- },
112
- /*
113
- Archivos de diseño editables. Es la categoría con más riesgo: un .psd con
114
- capas o un .aep pasan sin esfuerzo de 1 GB, y ninguno se puede previsualizar
115
- en el navegador —se muestran con icono y nombre—. El tope de aquí abajo es
116
- una propuesta, no un dato confirmado.
117
- */
118
- editable: {
119
- id: "editable",
120
- label: "Editable",
121
- description: "Archivo de diseño con capas (Photoshop, Illustrator, InDesign…)",
122
- mimes: [],
123
- extensions: [
124
- "psd", "psb", // Photoshop
125
- "ai", "eps", // Illustrator
126
- "indd", "idml", // InDesign
127
- "aep", // After Effects
128
- "prproj", // Premiere
129
- "sketch", "fig", "xd", // Sketch, Figma, Adobe XD
130
- "afphoto", "afdesign", // Affinity
131
- "svg",
132
- ],
133
- maxBytes: 500 * MB,
134
- validatesDuration: false,
135
- },
136
- comprimido: {
137
- id: "comprimido",
138
- label: "Comprimido",
139
- description: "Paquete de varios archivos en un solo zip",
140
- mimes: ["application/zip", "application/x-rar-compressed", "application/x-7z-compressed"],
141
- extensions: ["zip", "rar", "7z"],
142
- maxBytes: 500 * MB,
143
- validatesDuration: false,
144
- },
145
- otro: {
146
- id: "otro",
147
- label: "Otro",
148
- description: "Formato no reconocido",
149
- mimes: [],
150
- extensions: [],
151
- maxBytes: 50 * MB,
152
- validatesDuration: false,
153
- },
154
- };
155
-
156
- /** Orden en que se muestran en el selector de tipo. */
157
- export const ASSET_TYPE_ORDER = [
158
- "360",
159
- "video",
160
- "gif",
161
- "documento",
162
- "hoja_calculo",
163
- "editable",
164
- "comprimido",
165
- "otro",
166
- ];
167
-
168
- /** Opciones listas para el componente `Select` (espera `{ value, name }`). */
169
- export const assetTypeOptions = () =>
170
- ASSET_TYPE_ORDER.map((id) => ({
171
- value: id,
172
- name: ASSET_TYPES[id].label,
173
- }));
174
-
175
- /** Extensiones que el navegador pinta como imagen. */
176
- export const IMAGE_EXTENSIONS = [
177
- "jpg",
178
- "jpeg",
179
- "png",
180
- "webp",
181
- "gif",
182
- "bmp",
183
- "avif",
184
- "svg",
185
- "tiff",
186
- ];
187
-
188
- /**
189
- * Formatos de video que un navegador REPRODUCE de verdad.
190
- *
191
- * No es lo mismo que "es un video". Un .mov se sube bien y S3 lo sirve como
192
- * video/quicktime, pero Chrome y Firefox no lo reproducen: una grabación de
193
- * pantalla de macOS viene en HEVC o ProRes, que no saben decodificar. Tratarlo
194
- * como reproducible pinta un recuadro negro sin explicación; mostrarlo como
195
- * archivo dice la verdad y deja claro lo que sí se puede hacer: descargarlo.
196
- */
197
- export const PLAYABLE_VIDEO_EXTENSIONS = ["mp4", "webm", "ogv", "ogg"];
198
-
199
- /**
200
- * Cómo pintar un archivo: manda la extensión y el mime queda de respaldo.
201
- *
202
- * La extensión manda porque el mime miente en las dos direcciones: un .psd
203
- * llega sin él, y un .mov llega con uno que empieza por `video/` aunque el
204
- * navegador no sepa reproducirlo.
205
- *
206
- * NUNCA por la URL. `ProductImage` lo hacía con `/\.mp4$/` y por eso fallaba:
207
- * la URL guardada apunta al redimensionador de CloudFront y no conserva la
208
- * extensión del archivo, así que un video jamás daba positivo.
209
- */
210
- export const resolvePreviewKind = ({ mime, ext, name } = {}) => {
211
- const extension = (ext || getExtension(name) || "").toLowerCase();
212
-
213
- if (extension) {
214
- if (PLAYABLE_VIDEO_EXTENSIONS.includes(extension)) return ASSET_PREVIEW.VIDEO;
215
- if (IMAGE_EXTENSIONS.includes(extension)) return ASSET_PREVIEW.IMAGE;
216
- return ASSET_PREVIEW.ICON;
217
- }
218
-
219
- if (mime === "video/mp4" || mime === "video/webm") return ASSET_PREVIEW.VIDEO;
220
- if (mime?.startsWith("image/")) return ASSET_PREVIEW.IMAGE;
221
- return ASSET_PREVIEW.ICON;
222
- };
223
-
224
- export const getAssetType = (assetTypeId) =>
225
- ASSET_TYPES[assetTypeId] ?? ASSET_TYPES.otro;
226
-
227
- export const getExtension = (fileName = "") => {
228
- const parts = String(fileName).split(".");
229
- return parts.length > 1 ? parts.pop().toLowerCase() : "";
230
- };
231
-
232
- /*
233
- Qué tipo reclama cada extensión, resuelto una sola vez.
234
-
235
- El orden de `ASSET_TYPE_ORDER` decide los empates: `mp4` y `gif` aparecen
236
- también en "360", pero gana el tipo genérico porque es el caso común y el
237
- usuario puede reetiquetarlo en la tarjeta. Mirando el archivo no hay forma de
238
- saber si un giro es un giro.
239
- */
240
- const EXTENSION_INDEX = ASSET_TYPE_ORDER.reduce((index, id) => {
241
- ASSET_TYPES[id].extensions.forEach((ext) => {
242
- if (id !== "360" && !index[ext]) index[ext] = id;
243
- });
244
- return index;
245
- }, {});
246
-
247
- /**
248
- * Tipo de activo sugerido para un archivo recién elegido.
249
- *
250
- * Manda la extensión. El mime solo se consulta como respaldo para los formatos
251
- * multimedia, que son los únicos que el navegador reconoce con certeza: para un
252
- * .psd o un .ai reporta `application/octet-stream` o nada, y para un .ai a veces
253
- * `application/pdf`, que lo clasificaría como documento.
254
- */
255
- export const suggestAssetType = ({ mime, name } = {}) => {
256
- const ext = getExtension(name);
257
- if (EXTENSION_INDEX[ext]) return EXTENSION_INDEX[ext];
258
-
259
- if (mime?.startsWith("video/")) return "video";
260
- if (mime === "image/gif") return "gif";
261
- if (mime === "application/pdf") return "documento";
262
-
263
- return "otro";
264
- };
265
-
266
- /** Todos los mimes aceptados, sin repetir. */
267
- export const acceptedMimes = () => [
268
- ...new Set(ASSET_TYPE_ORDER.flatMap((id) => ASSET_TYPES[id].mimes)),
269
- ];
270
-
271
- /** Todas las extensiones aceptadas, sin repetir y con punto. */
272
- export const acceptedExtensions = () => [
273
- ...new Set(ASSET_TYPE_ORDER.flatMap((id) => ASSET_TYPES[id].extensions)),
274
- ].map((ext) => `.${ext}`);
275
-
276
- /**
277
- * Valor de `accept` para react-dropzone (v12: una cadena separada por comas).
278
- *
279
- * Lleva mimes Y extensiones. Con solo mimes, react-dropzone descartaría un
280
- * .psd o un .indd antes de que nuestra validación llegue a verlos —el navegador
281
- * no les asigna mime— y al soltarlos no pasaría absolutamente nada: ni el
282
- * archivo, ni un mensaje de por qué.
283
- */
284
- export const buildDropzoneAccept = () => [...acceptedMimes(), ...acceptedExtensions()].join(", ");
285
-
286
- /** Lista legible de formatos, para mensajes y para la ayuda de la bandeja. */
287
- export const acceptedFormatsLabel = () => acceptedExtensions()
288
- .map((ext) => ext.replace(".", "").toUpperCase())
289
- .join(", ");
290
-
291
- const GB = 1024 * MB;
292
-
293
- export const formatBytes = (bytes) => {
294
- const value = Number(bytes);
295
- if (!Number.isFinite(value) || value <= 0) return "-";
296
- if (value < KB) return `${value} B`;
297
- if (value < MB) return `${Math.round(value / KB)} KB`;
298
- // Los editables llegan a varios cientos de MB; sin este escalón un archivo de
299
- // 1.2 GB se anunciaba como "1228.8 MB".
300
- if (value < GB) return `${(value / MB).toFixed(1)} MB`;
301
- return `${(value / GB).toFixed(2)} GB`;
302
- };
303
-
304
- /** Duración compacta para la tarjeta del activo: "1:05". */
305
- export const formatDuration = (seconds) => {
306
- const value = Number(seconds);
307
- if (!Number.isFinite(value) || value <= 0) return "-";
308
- const mins = Math.floor(value / 60);
309
- const secs = Math.round(value % 60);
310
- return `${mins}:${String(secs).padStart(2, "0")}`;
311
- };
312
-
313
- /** Duración en prosa para los mensajes de error: "10 segundos", "10 minutos". */
314
- export const humanizeDuration = (seconds) => {
315
- const value = Number(seconds);
316
- if (!Number.isFinite(value) || value <= 0) return "-";
317
- if (value < 60) return `${Math.round(value)} segundos`;
318
- const mins = value / 60;
319
- const label = Number.isInteger(mins) ? mins : mins.toFixed(1);
320
- return `${label} ${mins === 1 ? "minuto" : "minutos"}`;
321
- };
322
-
323
- /**
324
- * Valida un archivo ANTES de tocar la red: tipo permitido, peso y cupo.
325
- *
326
- * Se separa de la subida a propósito. Da igual que el archivo termine yendo por
327
- * URL prefirmada o por el `putObject` de hoy: esto no cambia, y es lo que evita
328
- * que el navegador empiece a leer 200 MB que de todos modos se van a rechazar.
329
- *
330
- * @returns {{ valid: boolean, code?: string, message?: string }}
331
- */
332
- export const validateAssetFile = (file, { currentCount = 0 } = {}) => {
333
- if (!file) {
334
- return {
335
- valid: false,
336
- code: "NO_FILE",
337
- message: "No se recibió ningún archivo.",
338
- };
339
- }
340
-
341
- if (currentCount >= MAX_ASSETS_PER_PRODUCT) {
342
- return {
343
- valid: false,
344
- code: "MAX_ASSETS",
345
- message: `Solo puedes almacenar ${MAX_ASSETS_PER_PRODUCT} activos digitales por producto.`,
346
- };
347
- }
348
-
349
- const assetTypeId = suggestAssetType({ mime: file.type, name: file.name });
350
- const assetType = getAssetType(assetTypeId);
351
-
352
- if (assetTypeId === "otro") {
353
- return {
354
- valid: false,
355
- code: "UNSUPPORTED_TYPE",
356
- message: `"${file.name}" no es un formato permitido. Se aceptan: ${acceptedFormatsLabel()}.`,
357
- };
358
- }
359
-
360
- if (file.size > assetType.maxBytes) {
361
- return {
362
- valid: false,
363
- code: "MAX_SIZE",
364
- message: `"${file.name}" pesa ${formatBytes(
365
- file.size
366
- )} y el máximo para ${assetType.label} es ${formatBytes(
367
- assetType.maxBytes
368
- )}.`,
369
- };
370
- }
371
-
372
- return { valid: true, assetType: assetTypeId, warning: warnAssetFile(file, assetTypeId) };
373
- };
374
-
375
- /**
376
- * Aviso para un archivo que SÍ se acepta pero que el usuario no va a poder ver.
377
- *
378
- * El caso que sorprende es el video: alguien sube una grabación de pantalla de
379
- * macOS —un .mov— esperando reproducirla, y el navegador no la decodifica. El
380
- * archivo sirve igual para entregarlo al retailer, así que no se rechaza; pero
381
- * enterarse al soltarlo es mucho mejor que enterarse al intentar verlo.
382
- *
383
- * No se avisa de un .psd o un .indd: nadie espera previsualizar un archivo de
384
- * diseño, y un aviso por cada uno sería ruido.
385
- */
386
- export const warnAssetFile = (file, assetTypeId) => {
387
- if (assetTypeId !== "video" && assetTypeId !== "360") return null;
388
-
389
- const ext = getExtension(file?.name);
390
- if (PLAYABLE_VIDEO_EXTENSIONS.includes(ext)) return null;
391
-
392
- return `"${file?.name}" se guardará y se podrá descargar, pero el navegador no puede reproducir los .${ext}. Conviértelo a MP4 si necesitas verlo aquí.`;
393
- };
394
-
395
- /**
396
- * Valida la duración de un video. Va aparte de `validateAssetFile` porque
397
- * obtenerla es asíncrono (hay que cargar el archivo en un <video>) y no se
398
- * quiere pagar ese costo en archivos que ya fueron rechazados por peso.
399
- */
400
- export const validateAssetDuration = (seconds, assetTypeId) => {
401
- const assetType = getAssetType(assetTypeId);
402
- if (!assetType.validatesDuration) return { valid: true };
403
-
404
- const value = Number(seconds);
405
- if (!Number.isFinite(value)) return { valid: true };
406
-
407
- const { min, max } = VIDEO_DURATION_SECONDS;
408
- if (value < min || value > max) {
409
- return {
410
- valid: false,
411
- code: "DURATION",
412
- message: `Los videos deben durar entre ${humanizeDuration(
413
- min
414
- )} y ${humanizeDuration(max)}.`,
415
- };
416
- }
417
-
418
- return { valid: true };
419
- };
@@ -1,50 +0,0 @@
1
- /**
2
- * Vocabulario compartido del estado de activos digitales.
3
- *
4
- * Vive aquí, y no dentro del reducer, para que nadie dependa de nadie en la
5
- * dirección equivocada: el reducer de la ficha (`context/reducers/assets.js`)
6
- * y el hook de la bandeja (`useDigitalAssets`) importan ambos estas constantes,
7
- * y ninguno de los dos importa al otro. Es lo que permite probar el reducer
8
- * suelto en Storybook sin arrastrar la pantalla completa.
9
- */
10
-
11
- export const ASSET_ACTIONS = {
12
- /** Reemplaza toda la lista con lo que devolvió el backend. Limpia el rastreo. */
13
- SET_ASSETS_VALUES: "SET_ASSETS_VALUES",
14
- /** Agrega activos recién soltados y ya validados. */
15
- ADD_ASSETS: "ADD_ASSETS",
16
- /**
17
- * Cambio hecho por el usuario (reetiquetar el tipo, renombrar). Si el activo
18
- * ya existe en el backend, queda marcado para enviarse al guardar.
19
- */
20
- UPDATE_ASSET: "UPDATE_ASSET",
21
- /**
22
- * Cambio de ciclo de vida de la subida: progreso, estado, error, o la clave
23
- * de S3 que devuelve el backend. NO marca nada para guardar —el progreso de
24
- * una barra no es una edición del usuario— y por eso está separado de
25
- * `UPDATE_ASSET` en vez de ser una bandera.
26
- */
27
- PATCH_ASSET: "PATCH_ASSET",
28
- /** Quita un activo de la lista. Si ya existía en el backend, lo anota para borrarlo. */
29
- REMOVE_ASSET: "REMOVE_ASSET",
30
- SET_SELECTED_ASSETS: "SET_SELECTED_ASSETS",
31
- TOGGLE_CHECK_ALL_ASSETS: "TOGGLE_CHECK_ALL_ASSETS",
32
- /** Tras un guardado exitoso: olvida qué cambió y qué se borró. */
33
- CLEAR_ASSETS_TRACKING: "CLEAR_ASSETS_TRACKING",
34
- };
35
-
36
- export const assetsInitialState = {
37
- /** Lista completa, en el modelo de interfaz de `assets.adapter`. */
38
- assets_values: [],
39
- /**
40
- * Claves de los activos YA GUARDADOS que el usuario modificó. Se guardan
41
- * claves y no copias del objeto: una copia se queda vieja en cuanto el
42
- * usuario vuelve a tocar el activo, y al guardar se enviaría el valor
43
- * anterior. Con la clave, el objeto se lee siempre de `assets_values`.
44
- */
45
- updated_assets: [],
46
- /** Ids del backend pendientes de borrar. */
47
- deleted_assets: [],
48
- selected_assets: [],
49
- all_assets_checked: false,
50
- };
@@ -1,229 +0,0 @@
1
- /**
2
- * Traducción entre el activo digital como lo guarda el backend y como lo usa la
3
- * interfaz.
4
- *
5
- * DOS DECISIONES DEL NEGOCIO QUE SE NOTAN EN ESTE MODELO:
6
- *
7
- * - **Los activos no tienen versión.** Un activo general es el mismo sin
8
- * importar la versión del producto, así que no lleva `id_version` ni en el
9
- * modelo ni en la clave de S3. Eso evita el problema que sí tienen las
10
- * imágenes —copiar archivos al crear una versión— y hace que un video subido
11
- * en la v2 se vea también al mirar la v1.
12
- * - **Por ahora todos son generales**, o sea `id_retailer = null`. La columna
13
- * existe y este adaptador ya la respeta para cuando se pida acotar un activo
14
- * a una cadena; hasta entonces nada la escribe.
15
- *
16
- * Es el único archivo que conoce los nombres de campo del backend. Mientras el
17
- * contrato siga sin cerrarse, todo lo demás —tarjetas, bandeja, validaciones—
18
- * se construye contra el modelo de esta capa; cuando el contrato quede fijo se
19
- * ajustan estas funciones y nada más.
20
- *
21
- * El contrato propuesto está en `rel_article_asset`: una tabla aparte de
22
- * `rel_article_image`, porque esa última guarda una sola fila por
23
- * (artículo, versión, tipo de toma) y pisaría un activo con el siguiente.
24
- */
25
-
26
- import { getAssetType, getExtension, suggestAssetType } from "./assetTypes";
27
-
28
- /**
29
- * Estado de un activo en pantalla. `pending`/`uploading`/`error` solo existen
30
- * en el navegador: un activo con id del backend siempre está `saved`.
31
- */
32
- export const ASSET_STATUS = {
33
- PENDING: "pending",
34
- UPLOADING: "uploading",
35
- SAVED: "saved",
36
- ERROR: "error",
37
- };
38
-
39
- const IMAGES_BUCKET = () => process.env.REACT_APP_IMAGES_BUCKET;
40
-
41
- /**
42
- * URL directa al objeto en S3.
43
- *
44
- * A propósito NO pasa por el redimensionador de CloudFront: ese servicio
45
- * reescribe la imagen con sharp y para un mp4, un gif animado o un pdf
46
- * devuelve basura. Es la misma URL que ya usa la descarga de imágenes.
47
- */
48
- export const buildAssetUrl = (key) => {
49
- if (!key) return "";
50
- if (/^https?:\/\//i.test(key)) return key;
51
- return `https://${IMAGES_BUCKET()}.s3.amazonaws.com/${key}`;
52
- };
53
-
54
- /**
55
- * `Number(null)` y `Number("")` son 0, no NaN. Sin descartarlos antes, un
56
- * `id_retailer: null` —que significa "de todas las cadenas"— se convierte en el
57
- * retailer 0 y el activo deja de verse en ninguna; una duración nula se vuelve
58
- * un video de cero segundos.
59
- */
60
- const toNumberOrNull = (value) => {
61
- if (value === null || value === undefined || value === "") return null;
62
- const parsed = Number(value);
63
- return Number.isFinite(parsed) ? parsed : null;
64
- };
65
-
66
- /**
67
- * Fila del backend -> modelo de interfaz.
68
- *
69
- * Acepta `url` ya resuelta si el backend la manda; si no, la arma desde la
70
- * clave de S3. Así el front funciona igual con cualquiera de las dos formas del
71
- * contrato y no hay que esperar a que se decida para seguir construyendo.
72
- */
73
- export const fromBackend = (row = {}) => {
74
- const ext = row.ext || getExtension(row.name || row.src);
75
- const assetType =
76
- row.asset_type || suggestAssetType({ mime: row.mime, name: row.name });
77
-
78
- return {
79
- id: row.id ?? null,
80
- tempId: null,
81
- assetType,
82
- name: row.name || "",
83
- ext,
84
- mime: row.mime || "",
85
- sizeBytes: toNumberOrNull(row.size_bytes),
86
- duration: toNumberOrNull(row.duration),
87
- width: toNumberOrNull(row.width),
88
- height: toNumberOrNull(row.height),
89
- // Clave en S3. Se conserva porque es lo que el backend necesita de vuelta;
90
- // la URL es derivada y no debe viajar en el payload.
91
- src: row.src || "",
92
- url: row.url || buildAssetUrl(row.src),
93
- poster: row.poster ? buildAssetUrl(row.poster) : "",
94
- // `id_retailer` nulo = activo general, que es lo único que se escribe hoy.
95
- retailerId: toNumberOrNull(row.id_retailer),
96
- createdAt: row.created_at || null,
97
- file: null,
98
- status: ASSET_STATUS.SAVED,
99
- progress: 100,
100
- error: null,
101
- };
102
- };
103
-
104
- export const fromBackendList = (rows = []) =>
105
- Array.isArray(rows) ? rows.map(fromBackend) : [];
106
-
107
- let tempCounter = 0;
108
-
109
- /**
110
- * Archivo recién elegido -> modelo de interfaz.
111
- *
112
- * El `File` nativo se guarda tal cual, sin leerlo a base64. Convertirlo cuesta
113
- * un tercio más de memoria y es lo que hoy vuelve imposible subir un video
114
- * largo o un manual grande; con el `File` intacto da igual si termina subiendo
115
- * por URL prefirmada o por el cliente de S3.
116
- *
117
- * El `tempId` permite seguirle la pista a un activo que todavía no tiene id del
118
- * backend: sin él no hay forma de actualizar su progreso ni de quitarlo de la
119
- * lista si falla.
120
- */
121
- export const fromFile = (file, { assetType, objectUrl } = {}) => {
122
- tempCounter += 1;
123
- const ext = getExtension(file?.name);
124
-
125
- return {
126
- id: null,
127
- tempId: `tmp-${Date.now()}-${tempCounter}`,
128
- assetType:
129
- assetType || suggestAssetType({ mime: file?.type, name: file?.name }),
130
- name: file?.name || "",
131
- ext,
132
- mime: file?.type || "",
133
- sizeBytes: toNumberOrNull(file?.size),
134
- duration: null,
135
- width: null,
136
- height: null,
137
- src: "",
138
- // `URL.createObjectURL` deja ver el archivo sin subirlo ni codificarlo.
139
- // Quien lo crea es responsable de liberarlo con `revokeAssetUrl`.
140
- url: objectUrl || "",
141
- poster: "",
142
- retailerId: null,
143
- createdAt: null,
144
- file,
145
- status: ASSET_STATUS.PENDING,
146
- progress: 0,
147
- error: null,
148
- };
149
- };
150
-
151
- /** Identificador estable de un activo, tenga o no id del backend. */
152
- export const assetKey = (asset = {}) =>
153
- asset.id ?? asset.tempId ?? asset.src ?? asset.name;
154
-
155
- export const isPersisted = (asset = {}) =>
156
- asset.id !== null && asset.id !== undefined;
157
-
158
- /**
159
- * Modelo de interfaz -> payload del backend.
160
- *
161
- * Nunca incluye `file` ni `url`: el archivo ya está en S3 cuando esto se envía
162
- * y la URL es derivada. Es la misma razón por la que el guardado de imágenes
163
- * borra `src`/`video_src` antes del PUT — el binario en el cuerpo devuelve 413.
164
- */
165
- export const toPayload = (asset = {}) => {
166
- const payload = {
167
- asset_type: asset.assetType,
168
- name: asset.name,
169
- ext: asset.ext,
170
- mime: asset.mime,
171
- size_bytes: asset.sizeBytes ?? null,
172
- duration: asset.duration ?? null,
173
- width: asset.width ?? null,
174
- height: asset.height ?? null,
175
- key: asset.src || null,
176
- };
177
-
178
- if (isPersisted(asset)) payload.id = asset.id;
179
- if (asset.retailerId !== null && asset.retailerId !== undefined) {
180
- payload.id_retailer = asset.retailerId;
181
- }
182
-
183
- return payload;
184
- };
185
-
186
- /**
187
- * Separa la lista en lo que el backend espera recibir: altas, cambios y bajas.
188
- * `deleted` son ids, no objetos, porque para borrar no hace falta nada más.
189
- */
190
- export const buildSavePayload = ({
191
- assets = [],
192
- updated = [],
193
- deleted = [],
194
- } = {}) => ({
195
- newAssets: assets.filter((asset) => !isPersisted(asset)).map(toPayload),
196
- updateAssets: updated.filter(isPersisted).map(toPayload),
197
- deleteAssets: deleted
198
- .map((asset) => (typeof asset === "object" ? asset.id : asset))
199
- .filter(Boolean),
200
- });
201
-
202
- /**
203
- * Qué activos ve la cadena activa: los generales (`retailerId` nulo) más los
204
- * suyos.
205
- *
206
- * Hoy todos son generales, así que en la práctica no filtra nada. Se deja
207
- * escrito y probado para el día que se pida acotar un activo a una cadena: ese
208
- * día basta con que el backend mande el `id_retailer`, sin tocar la pantalla.
209
- */
210
- export const filterByRetailer = (assets = [], retailerId) => {
211
- if (!retailerId) return assets;
212
- return assets.filter(
213
- (asset) =>
214
- asset.retailerId === null ||
215
- asset.retailerId === undefined ||
216
- Number(asset.retailerId) === Number(retailerId)
217
- );
218
- };
219
-
220
- /** Libera el object URL de un activo local. Sin esto el archivo se queda en memoria. */
221
- export const revokeAssetUrl = (asset = {}) => {
222
- if (asset.file && asset.url && asset.url.startsWith("blob:")) {
223
- URL.revokeObjectURL(asset.url);
224
- }
225
- };
226
-
227
- /** Etiqueta legible del tipo, para badges y tablas. */
228
- export const assetTypeLabel = (asset = {}) =>
229
- getAssetType(asset.assetType).label;