contentoh-components-library 21.6.56 → 21.6.57

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 (43) hide show
  1. package/dist/components/atoms/AsignationOption/index.js +1 -0
  2. package/dist/components/atoms/AsignationOption/styles.js +1 -1
  3. package/dist/components/molecules/GalleryAssetElement/styles.js +1 -1
  4. package/dist/components/molecules/RetailerSelector/RetailerSelector.stories.js +23 -2
  5. package/dist/components/molecules/RetailerSelector/index.js +7 -2
  6. package/dist/components/molecules/RetailerSelectorv2/index.js +6 -2
  7. package/dist/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +73 -9
  8. package/dist/components/organisms/DigitalAssetsTray/index.js +13 -5
  9. package/dist/components/pages/CreatorsProductEdition/CreatorsProductEditionView.js +905 -0
  10. package/dist/components/pages/CreatorsProductEdition/constants.js +19 -0
  11. package/dist/components/pages/CreatorsProductEdition/hooks/useCreatorsProductEdition.js +2967 -0
  12. package/dist/components/pages/CreatorsProductEdition/index.js +33 -0
  13. package/dist/components/pages/CreatorsProductEdition/mocks/api.fixtures.js +537 -0
  14. package/dist/components/pages/CreatorsProductEdition/mocks/mockApi.js +251 -0
  15. package/dist/components/pages/CreatorsProductEdition/sections/CommentBox.js +311 -0
  16. package/dist/components/pages/CreatorsProductEdition/sections/ImagePanel.js +421 -0
  17. package/dist/components/pages/CreatorsProductEdition/sections/ImagesSection.js +473 -0
  18. package/dist/components/pages/CreatorsProductEdition/sections/ProductHeader.js +379 -0
  19. package/dist/components/pages/CreatorsProductEdition/sections/ProductMedia.js +138 -0
  20. package/dist/components/pages/CreatorsProductEdition/sections/RetailerMenu.js +162 -0
  21. package/dist/components/pages/CreatorsProductEdition/sections/ServiceActions.js +542 -0
  22. package/dist/components/pages/CreatorsProductEdition/sections/StatusChip.js +50 -0
  23. package/dist/components/pages/CreatorsProductEdition/sections/TabsBar.js +247 -0
  24. package/dist/components/pages/CreatorsProductEdition/sections/VariantsPanel.js +389 -0
  25. package/dist/components/pages/CreatorsProductEdition/sections/tokens.js +90 -0
  26. package/dist/components/pages/CreatorsProductEdition/stories/CreatorsProductEdition.stories.js +168 -0
  27. package/dist/components/pages/CreatorsProductEdition/stories/CreatorsProductEditionAPI.stories.js +74 -0
  28. package/dist/components/pages/CreatorsProductEdition/styles.js +46 -0
  29. package/dist/global-files/digital-assets/assetTypes.js +49 -12
  30. package/dist/global-files/digital-assets/uploadAssets.js +39 -16
  31. package/dist/global-files/digital-assets/useDigitalAssets.js +38 -25
  32. package/package.json +1 -1
  33. package/src/components/atoms/AsignationOption/index.js +1 -1
  34. package/src/components/atoms/AsignationOption/styles.js +9 -0
  35. package/src/components/molecules/GalleryAssetElement/styles.js +5 -1
  36. package/src/components/molecules/RetailerSelector/RetailerSelector.stories.js +15 -1
  37. package/src/components/molecules/RetailerSelector/index.js +8 -1
  38. package/src/components/molecules/RetailerSelectorv2/index.js +7 -1
  39. package/src/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +67 -4
  40. package/src/components/organisms/DigitalAssetsTray/index.js +8 -4
  41. package/src/global-files/digital-assets/assetTypes.js +42 -10
  42. package/src/global-files/digital-assets/uploadAssets.js +38 -23
  43. package/src/global-files/digital-assets/useDigitalAssets.js +19 -8
@@ -353,10 +353,13 @@ var useDigitalAssets = function useDigitalAssets() {
353
353
  _Array$from,
354
354
  file,
355
355
  check,
356
+ warning,
356
357
  objectUrl,
357
358
  asset,
359
+ isVideoFile,
358
360
  duration,
359
361
  durationCheck,
362
+ _warning,
360
363
  _args3 = arguments;
361
364
 
362
365
  return _regenerator.default.wrap(function _callee3$(_context3) {
@@ -371,7 +374,7 @@ var useDigitalAssets = function useDigitalAssets() {
371
374
 
372
375
  case 5:
373
376
  if (!(_i < _Array$from.length)) {
374
- _context3.next = 29;
377
+ _context3.next = 31;
375
378
  break;
376
379
  }
377
380
 
@@ -391,39 +394,37 @@ var useDigitalAssets = function useDigitalAssets() {
391
394
  code: check.code,
392
395
  message: check.message
393
396
  });
394
- return _context3.abrupt("continue", 26);
397
+ return _context3.abrupt("continue", 28);
395
398
 
396
399
  case 11:
397
- // Se acepta, pero hay algo que decirle al usuario sobre este archivo.
398
- if (check.warning) {
399
- avisos.push({
400
- level: "warning",
401
- name: file.name,
402
- message: check.warning
403
- });
404
- }
405
-
400
+ // Se acepta, pero hay algo que decirle al usuario. Se junta aquí y se
401
+ // publica solo si el archivo termina agregándose: si luego lo rechaza la
402
+ // duración, un "se agregaron" junto al error sería contradictorio.
403
+ warning = check.warning;
406
404
  objectUrl = URL.createObjectURL(file);
407
405
  asset = (0, _assets2.fromFile)(file, {
408
406
  assetType: check.assetType,
409
407
  objectUrl: objectUrl
410
408
  }); // La duración solo se mide en lo que la valida, y después del peso: no
411
- // tiene sentido abrir un archivo que ya se rechazó.
409
+ // tiene sentido abrir un archivo que ya se rechazó. Un 360 en gif no es
410
+ // un video: <video> no lo abre y no hay duración que medir.
412
411
 
413
- if (!(check.assetType === "video" || check.assetType === "360")) {
414
- _context3.next = 24;
412
+ isVideoFile = (0, _assetTypes.getExtension)(file.name) !== "gif";
413
+
414
+ if (!((check.assetType === "video" || check.assetType === "360") && isVideoFile)) {
415
+ _context3.next = 25;
415
416
  break;
416
417
  }
417
418
 
418
- _context3.next = 17;
419
+ _context3.next = 18;
419
420
  return readVideoDuration(objectUrl);
420
421
 
421
- case 17:
422
+ case 18:
422
423
  duration = _context3.sent;
423
- durationCheck = (0, _assetTypes.validateAssetDuration)(duration, check.assetType);
424
+ durationCheck = (0, _assetTypes.validateAssetDuration)(duration, check.assetType, file.name);
424
425
 
425
426
  if (durationCheck.valid) {
426
- _context3.next = 23;
427
+ _context3.next = 24;
427
428
  break;
428
429
  }
429
430
 
@@ -434,21 +435,33 @@ var useDigitalAssets = function useDigitalAssets() {
434
435
  code: durationCheck.code,
435
436
  message: durationCheck.message
436
437
  });
437
- return _context3.abrupt("continue", 26);
438
-
439
- case 23:
440
- asset.duration = duration;
438
+ return _context3.abrupt("continue", 28);
441
439
 
442
440
  case 24:
441
+ if (duration === null) {
442
+ warning = (_warning = warning) !== null && _warning !== void 0 ? _warning : (0, _assetTypes.warnUnreadableVideo)(file.name);
443
+ } else {
444
+ asset.duration = duration;
445
+ }
446
+
447
+ case 25:
448
+ if (warning) {
449
+ avisos.push({
450
+ level: "warning",
451
+ name: file.name,
452
+ message: warning
453
+ });
454
+ }
455
+
443
456
  accepted.push(asset);
444
457
  count += 1;
445
458
 
446
- case 26:
459
+ case 28:
447
460
  _i++;
448
461
  _context3.next = 5;
449
462
  break;
450
463
 
451
- case 29:
464
+ case 31:
452
465
  if (accepted.length) {
453
466
  countRef.current = count;
454
467
  dispatch({
@@ -466,7 +479,7 @@ var useDigitalAssets = function useDigitalAssets() {
466
479
  notices: avisos
467
480
  });
468
481
 
469
- case 32:
482
+ case 34:
470
483
  case "end":
471
484
  return _context3.stop();
472
485
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contentoh-components-library",
3
- "version": "21.6.56",
3
+ "version": "21.6.57",
4
4
  "dependencies": {
5
5
  "@aws-amplify/auth": "^4.5.3",
6
6
  "@aws-amplify/datastore": "^3.11.0",
@@ -38,7 +38,7 @@ export const AsignationOption = ({
38
38
  };
39
39
 
40
40
  return (
41
- <Container onClick={onClick}>
41
+ <Container onClick={onClick} className={active ? "active-option" : undefined}>
42
42
  <Avatar
43
43
  imageType={imageType}
44
44
  image={profileImage}
@@ -63,6 +63,15 @@ export const Container = styled.div`
63
63
  background-color: ${GlobalColors.s3};
64
64
  }
65
65
 
66
+ /* La opción seleccionada (hoy: la cadena activa en el selector de cadenas). */
67
+ &.active-option {
68
+ background-color: rgba(${GlobalColors.rgb_color_pink}, 0.1);
69
+
70
+ &:hover {
71
+ background-color: rgba(${GlobalColors.rgb_color_pink}, 0.18);
72
+ }
73
+ }
74
+
66
75
  & + * {
67
76
  margin-top: 10px;
68
77
  }
@@ -126,9 +126,13 @@ export const Container = styled.div`
126
126
  transition: width 0.2s ease-out;
127
127
  }
128
128
 
129
+ /* En la fila, que es una rejilla de dos columnas, el mensaje ocupa todo el
130
+ ancho. Si no, cae en la columna del checkbox, la ensancha hasta su palabra
131
+ más larga y descuadra la fila contra las demás. */
129
132
  .asset-error-message {
133
+ grid-column: 1 / -1;
130
134
  margin: 2px 0 0;
131
- padding: 0 8px;
135
+ padding: 0 8px 6px;
132
136
  font-family: ${FontFamily.Lato};
133
137
  font-size: 11px;
134
138
  line-height: 15px;
@@ -1,3 +1,4 @@
1
+ import { useState } from "react";
1
2
  import { RetailerSelector } from "./index";
2
3
 
3
4
  export default {
@@ -5,7 +6,20 @@ export default {
5
6
  component: RetailerSelector,
6
7
  };
7
8
 
8
- const Template = (args) => <RetailerSelector {...args} />;
9
+ // El panel se abre hacia la izquierda del avatar: sin margen quedaría fuera de
10
+ // la vista. La cadena activa vive en estado para que elegir otra se vea.
11
+ const Template = (args) => {
12
+ const [activeRetailer, setActiveRetailer] = useState(args.retailers[1]);
13
+ return (
14
+ <div style={{ display: "flex", justifyContent: "flex-end", width: 420 }}>
15
+ <RetailerSelector
16
+ {...args}
17
+ activeRetailer={activeRetailer}
18
+ setActiveRetailer={setActiveRetailer}
19
+ />
20
+ </div>
21
+ );
22
+ };
9
23
 
10
24
  export const RetailerSelectorDefault = Template.bind({});
11
25
  RetailerSelectorDefault.args = {
@@ -89,7 +89,14 @@ export const RetailerSelector = ({
89
89
  );
90
90
  setRetailersPanel(false);
91
91
  }}
92
- active={retailer.id === activeRetailer.id}
92
+ // El id llega como `id` o como `id_retailer` según el origen
93
+ // (en RetailerProductEdition, `categoryRetailerInOrder` usa
94
+ // `id_retailer`); comparar solo `id` nunca marcaba la activa.
95
+ active={
96
+ (retailer?.id ?? retailer?.id_retailer) != null &&
97
+ (retailer?.id ?? retailer?.id_retailer) ===
98
+ (activeRetailer?.id ?? activeRetailer?.id_retailer)
99
+ }
93
100
  showStatus={showStatus}
94
101
  />
95
102
  ))}
@@ -69,7 +69,13 @@ export const RetailerSelectorv2 = ({
69
69
  );
70
70
  setRetailersPanel(false);
71
71
  }}
72
- active={retailer.id === activeRetailer.id}
72
+ // El id llega como `id` o como `id_retailer` según el origen;
73
+ // comparar solo `id` nunca marcaba la cadena activa.
74
+ active={
75
+ (retailer?.id ?? retailer?.id_retailer) != null &&
76
+ (retailer?.id ?? retailer?.id_retailer) ===
77
+ (activeRetailer?.id ?? activeRetailer?.id_retailer)
78
+ }
73
79
  showStatus={showStatus}
74
80
  />
75
81
  ))}
@@ -9,7 +9,12 @@ import {
9
9
  hasUnsavedAssets,
10
10
  selectAssetsToSave,
11
11
  } from "../../../global-files/digital-assets/assets.reducer";
12
- import { buildSavePayload } from "../../../global-files/digital-assets/assets.adapter";
12
+ import {
13
+ ASSET_STATUS,
14
+ buildSavePayload,
15
+ fromFile,
16
+ } from "../../../global-files/digital-assets/assets.adapter";
17
+ import { ASSET_ACTIONS } from "../../../global-files/digital-assets/assets.actions";
13
18
  import { backendAssetsMock } from "../../../global-files/digital-assets/assets.mock";
14
19
 
15
20
  export default {
@@ -22,7 +27,13 @@ export default {
22
27
  Así lo que se ve aquí es el comportamiento real: qué queda marcado para
23
28
  guardar, qué se anota para borrar y qué payload saldría hacia el backend.
24
29
  */
25
- const Demo = ({ initialAssets = [], gridLayout = false, readOnly = false, showPayload = false }) => {
30
+ const Demo = ({
31
+ initialAssets = [],
32
+ localAssets = [],
33
+ gridLayout = false,
34
+ readOnly = false,
35
+ showPayload = false,
36
+ }) => {
26
37
  const [state, dispatch] = useReducer(assetsReducer, assetsInitialState);
27
38
  const [notices, setNotices] = useState([]);
28
39
 
@@ -35,8 +46,16 @@ const Demo = ({ initialAssets = [], gridLayout = false, readOnly = false, showPa
35
46
 
36
47
  // Carga inicial simulando la respuesta del backend, una sola vez.
37
48
  const [loaded, setLoaded] = useState(false);
38
- if (!loaded && initialAssets.length) {
39
- tray.replaceAll(initialAssets);
49
+ if (!loaded && (initialAssets.length || localAssets.length)) {
50
+ if (initialAssets.length) tray.replaceAll(initialAssets);
51
+ // Archivos locales ya en un estado de subida (subiendo, con error...), para
52
+ // ver cómo se pintan sin tener que provocar el fallo contra el backend.
53
+ if (localAssets.length) {
54
+ dispatch({
55
+ type: ASSET_ACTIONS.ADD_ASSETS,
56
+ payload: localAssets.map(({ file, ...estado }) => ({ ...fromFile(file), ...estado })),
57
+ });
58
+ }
40
59
  setLoaded(true);
41
60
  }
42
61
 
@@ -99,3 +118,47 @@ export const SoloLectura = () => <Demo initialAssets={backendAssetsMock} readOnl
99
118
  * se ha subido a S3.
100
119
  */
101
120
  export const PayloadDeGuardado = () => <Demo initialAssets={backendAssetsMock} showPayload />;
121
+
122
+ const archivo = (name, type) => new File(["x"], name, { type });
123
+
124
+ /**
125
+ * Cómo se ve una subida que falla: el motivo aparece debajo del archivo y el
126
+ * resto sigue su curso. Los textos son los que producen de verdad
127
+ * `uploadAssets.js` (S3 y firma) y `presign-asset-upload` (rechazo del servidor).
128
+ */
129
+ export const ErroresDeSubida = () => (
130
+ <Demo
131
+ localAssets={[
132
+ {
133
+ file: archivo("video-campana.mp4", "video/mp4"),
134
+ status: ASSET_STATUS.UPLOADING,
135
+ progress: 45,
136
+ },
137
+ {
138
+ file: archivo("manual-instalacion.pdf", "application/pdf"),
139
+ status: ASSET_STATUS.ERROR,
140
+ error: "No se pudo subir el archivo. Avisa a soporte (permiso de almacenamiento).",
141
+ },
142
+ {
143
+ file: archivo("render-final.psd", ""),
144
+ status: ASSET_STATUS.ERROR,
145
+ error: "El archivo supera el tamaño permitido.",
146
+ },
147
+ {
148
+ file: archivo("tabla-tallas.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
149
+ status: ASSET_STATUS.ERROR,
150
+ error: "No se pudo conectar para subir el archivo. Revisa tu conexión e inténtalo de nuevo.",
151
+ },
152
+ {
153
+ file: archivo("foto-lifestyle.jpg", "image/jpeg"),
154
+ status: ASSET_STATUS.ERROR,
155
+ error: "El archivo pesa más del máximo permitido para imagen",
156
+ },
157
+ {
158
+ file: archivo("catalogo.zip", "application/zip"),
159
+ status: ASSET_STATUS.ERROR,
160
+ error: "No se pudo preparar la subida. Revisa tu conexión e inténtalo de nuevo.",
161
+ },
162
+ ]}
163
+ />
164
+ );
@@ -47,7 +47,11 @@ export const DigitalAssetsTray = ({
47
47
  multiple: true,
48
48
  noClick: true,
49
49
  disabled: readOnly,
50
- onDrop: (acceptedFiles) => addFiles?.(acceptedFiles),
50
+ // Los rechazados también pasan: react-dropzone los descarta por `accept`
51
+ // antes que nuestra validación, y sin esto soltar un formato no permitido
52
+ // no hacía nada. `addFiles` los valida y avisa por qué no entran.
53
+ onDrop: (acceptedFiles, fileRejections) =>
54
+ addFiles?.([...acceptedFiles, ...fileRejections.map(({ file }) => file)]),
51
55
  });
52
56
 
53
57
  const noticesBy = (level) => notices.filter((item) => item.level === level);
@@ -66,11 +70,11 @@ export const DigitalAssetsTray = ({
66
70
  return (
67
71
  <Container>
68
72
  <div className="tray-toolbar">
69
- {/* La lista completa son 27 formatos: no cabe en una línea, así que va
73
+ {/* La lista completa son 35 formatos: no cabe en una línea, así que va
70
74
  en el title y en el texto solo las familias. */}
71
75
  <p className="tray-hint" title={`Formatos permitidos: ${acceptedFormatsLabel()}`}>
72
- Arrastra aquí videos, 360, GIF, documentos, hojas de cálculo o archivos
73
- editables. Quedan {remaining} de {MAX_ASSETS_PER_PRODUCT}.
76
+ Arrastra aquí imágenes, videos, 360, GIF, documentos, hojas de cálculo
77
+ o archivos editables. Quedan {remaining} de {MAX_ASSETS_PER_PRODUCT}.
74
78
  </p>
75
79
  {!readOnly && (
76
80
  <ButtonV2
@@ -56,6 +56,23 @@ export const VIDEO_DURATION_SECONDS = {
56
56
  };
57
57
 
58
58
  export const ASSET_TYPES = {
59
+ /*
60
+ Imágenes sueltas: fotos de ambientación, lifestyle, material de campaña.
61
+ No sustituyen a la galería de la pestaña Imágenes, que valida tomas
62
+ contra `image_manager`; aquí no cuentan para el avance ni tienen orden.
63
+
64
+ El gif tiene su propio tipo y el svg vive en "editable": se sirve como
65
+ descarga porque un svg abierto en el navegador ejecuta scripts.
66
+ */
67
+ imagen: {
68
+ id: "imagen",
69
+ label: "Imagen",
70
+ description: "Foto o imagen suelta (ambientación, lifestyle, campaña)",
71
+ mimes: ["image/jpeg", "image/png", "image/webp", "image/bmp", "image/avif", "image/tiff"],
72
+ extensions: ["jpg", "jpeg", "png", "webp", "bmp", "avif", "tif", "tiff"],
73
+ maxBytes: 50 * MB,
74
+ validatesDuration: false,
75
+ },
59
76
  360: {
60
77
  id: "360",
61
78
  label: "360",
@@ -155,6 +172,7 @@ export const ASSET_TYPES = {
155
172
 
156
173
  /** Orden en que se muestran en el selector de tipo. */
157
174
  export const ASSET_TYPE_ORDER = [
175
+ "imagen",
158
176
  "360",
159
177
  "video",
160
178
  "gif",
@@ -338,14 +356,8 @@ export const validateAssetFile = (file, { currentCount = 0 } = {}) => {
338
356
  };
339
357
  }
340
358
 
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
-
359
+ // El cupo se revisa al final: un archivo que no se puede subir de todos modos
360
+ // (formato o peso) debe decir ESA razón, no "ya no hay lugar".
349
361
  const assetTypeId = suggestAssetType({ mime: file.type, name: file.name });
350
362
  const assetType = getAssetType(assetTypeId);
351
363
 
@@ -369,6 +381,14 @@ export const validateAssetFile = (file, { currentCount = 0 } = {}) => {
369
381
  };
370
382
  }
371
383
 
384
+ if (currentCount >= MAX_ASSETS_PER_PRODUCT) {
385
+ return {
386
+ valid: false,
387
+ code: "MAX_ASSETS",
388
+ message: `"${file.name}" no se agregó: solo puedes almacenar ${MAX_ASSETS_PER_PRODUCT} activos digitales por producto.`,
389
+ };
390
+ }
391
+
372
392
  return { valid: true, assetType: assetTypeId, warning: warnAssetFile(file, assetTypeId) };
373
393
  };
374
394
 
@@ -392,15 +412,27 @@ export const warnAssetFile = (file, assetTypeId) => {
392
412
  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
413
  };
394
414
 
415
+ /**
416
+ * Aviso para un video cuya duración el navegador no pudo leer (códec que no
417
+ * conoce, archivo dañado). No se rechaza: sirve igual para entregarlo, solo que
418
+ * aquí no se podrá ver ni se sabe cuánto dura.
419
+ */
420
+ export const warnUnreadableVideo = (fileName) =>
421
+ `"${fileName}" se guardará y se podrá descargar, pero el navegador no pudo leerlo: no se podrá reproducir aquí ni se validó su duración.`;
422
+
395
423
  /**
396
424
  * Valida la duración de un video. Va aparte de `validateAssetFile` porque
397
425
  * obtenerla es asíncrono (hay que cargar el archivo en un <video>) y no se
398
426
  * quiere pagar ese costo en archivos que ya fueron rechazados por peso.
427
+ *
428
+ * `seconds` en null significa "no se pudo medir" y no se rechaza: `Number(null)`
429
+ * es 0 y, sin esta guarda, todo video ilegible salía como "dura menos de 10 s".
399
430
  */
400
- export const validateAssetDuration = (seconds, assetTypeId) => {
431
+ export const validateAssetDuration = (seconds, assetTypeId, fileName = "El video") => {
401
432
  const assetType = getAssetType(assetTypeId);
402
433
  if (!assetType.validatesDuration) return { valid: true };
403
434
 
435
+ if (seconds === null || seconds === undefined) return { valid: true };
404
436
  const value = Number(seconds);
405
437
  if (!Number.isFinite(value)) return { valid: true };
406
438
 
@@ -409,7 +441,7 @@ export const validateAssetDuration = (seconds, assetTypeId) => {
409
441
  return {
410
442
  valid: false,
411
443
  code: "DURATION",
412
- message: `Los videos deben durar entre ${humanizeDuration(
444
+ message: `"${fileName}" dura ${humanizeDuration(value)}; los videos deben durar entre ${humanizeDuration(
413
445
  min
414
446
  )} y ${humanizeDuration(max)}.`,
415
447
  };
@@ -29,20 +29,28 @@ import { toPayload } from "./assets.adapter";
29
29
  * bucket.
30
30
  */
31
31
  export const requestPresign = async ({ articleId, assets, token }) => {
32
- const { data } = await axios.post(
33
- process.env.REACT_APP_ASSETS_PRESIGN_ENDPOINT,
34
- {
35
- articleId,
36
- files: assets.map((asset) => ({
37
- // El `ref` vuelve tal cual en la respuesta. Emparejar por nombre sería
38
- // frágil: dos archivos que se llamen igual acabarían con la clave del
39
- // otro.
40
- ref: asset.tempId,
41
- ...toPayload(asset),
42
- })),
43
- },
44
- { headers: { Authorization: token } },
45
- );
32
+ let data;
33
+ try {
34
+ ({ data } = await axios.post(
35
+ process.env.REACT_APP_ASSETS_PRESIGN_ENDPOINT,
36
+ {
37
+ articleId,
38
+ files: assets.map((asset) => ({
39
+ // El `ref` vuelve tal cual en la respuesta. Emparejar por nombre sería
40
+ // frágil: dos archivos que se llamen igual acabarían con la clave del
41
+ // otro.
42
+ ref: asset.tempId,
43
+ ...toPayload(asset),
44
+ })),
45
+ },
46
+ { headers: { Authorization: token } },
47
+ ));
48
+ } catch (error) {
49
+ // "Network Error" o "Request failed with status code 502" no le dicen nada
50
+ // al usuario. El detalle queda en la consola para quien lo diagnostique.
51
+ console.error("Activos digitales: falló la firma de la subida", error);
52
+ throw new Error("No se pudo preparar la subida. Revisa tu conexión e inténtalo de nuevo.");
53
+ }
46
54
 
47
55
  // `integration: lambda` de API Gateway devuelve el objeto del handler tal
48
56
  // cual, con el cuerpo como cadena, en vez de traducirlo a una respuesta HTTP.
@@ -91,17 +99,24 @@ export const uploadToS3 = ({ upload, file, onProgress }) => new Promise((resolve
91
99
  desplegó solo la función y no el servicio completo.
92
100
  */
93
101
  const code = /<Code>(.*?)<\/Code>/.exec(xhr.responseText)?.[1];
94
- reject(new Error(code === "EntityTooLarge"
95
- ? "El archivo supera el tamaño permitido."
96
- : `S3 rechazó la subida (${code || xhr.status}).`));
102
+ // El código técnico va a la consola; al usuario, algo que pueda hacer.
103
+ console.error("Activos digitales: S3 rechazó la subida", code || xhr.status, xhr.responseText);
104
+ if (code === "EntityTooLarge") {
105
+ reject(new Error("El archivo supera el tamaño permitido."));
106
+ } else if (code === "AccessDenied") {
107
+ reject(new Error("No se pudo subir el archivo. Avisa a soporte (permiso de almacenamiento)."));
108
+ } else {
109
+ reject(new Error(`No se pudo subir el archivo (${code || xhr.status}). Inténtalo de nuevo; si sigue fallando, avisa a soporte.`));
110
+ }
97
111
  };
98
112
 
99
- xhr.onerror = () => reject(new Error(
100
- // Un fallo de red sin estatus casi siempre es CORS: el bucket tiene que
101
- // permitir POST desde el origen de la aplicación. El navegador corta la
102
- // petición antes de mandarla y no deja rastro en ningún log del servidor.
103
- "No se pudo conectar con el almacenamiento. Revisa la configuración CORS del bucket.",
104
- ));
113
+ xhr.onerror = () => {
114
+ // Un fallo de red sin estatus casi siempre es CORS (el bucket tiene que
115
+ // permitir POST desde el origen de la aplicación) o una conexión caída. El
116
+ // navegador corta la petición y no deja rastro en ningún log del servidor.
117
+ console.error("Activos digitales: no hubo respuesta de S3 (red o CORS del bucket)");
118
+ reject(new Error("No se pudo conectar para subir el archivo. Revisa tu conexión e inténtalo de nuevo."));
119
+ };
105
120
 
106
121
  xhr.onabort = () => reject(new Error("Subida cancelada."));
107
122
 
@@ -4,8 +4,10 @@ import { ASSET_ACTIONS } from "./assets.actions";
4
4
  import { requestPresign, uploadToS3 } from "./uploadAssets";
5
5
  import {
6
6
  MAX_ASSETS_PER_PRODUCT,
7
+ getExtension,
7
8
  validateAssetDuration,
8
9
  validateAssetFile,
10
+ warnUnreadableVideo,
9
11
  } from "./assetTypes";
10
12
  import {
11
13
  ASSET_STATUS,
@@ -243,19 +245,21 @@ export const useDigitalAssets = ({
243
245
  });
244
246
  continue;
245
247
  }
246
- // Se acepta, pero hay algo que decirle al usuario sobre este archivo.
247
- if (check.warning) {
248
- avisos.push({ level: "warning", name: file.name, message: check.warning });
249
- }
248
+ // Se acepta, pero hay algo que decirle al usuario. Se junta aquí y se
249
+ // publica solo si el archivo termina agregándose: si luego lo rechaza la
250
+ // duración, un "se agregaron" junto al error sería contradictorio.
251
+ let warning = check.warning;
250
252
 
251
253
  const objectUrl = URL.createObjectURL(file);
252
254
  const asset = fromFile(file, { assetType: check.assetType, objectUrl });
253
255
 
254
256
  // La duración solo se mide en lo que la valida, y después del peso: no
255
- // tiene sentido abrir un archivo que ya se rechazó.
256
- if (check.assetType === "video" || check.assetType === "360") {
257
+ // tiene sentido abrir un archivo que ya se rechazó. Un 360 en gif no es
258
+ // un video: <video> no lo abre y no hay duración que medir.
259
+ const isVideoFile = getExtension(file.name) !== "gif";
260
+ if ((check.assetType === "video" || check.assetType === "360") && isVideoFile) {
257
261
  const duration = await readVideoDuration(objectUrl);
258
- const durationCheck = validateAssetDuration(duration, check.assetType);
262
+ const durationCheck = validateAssetDuration(duration, check.assetType, file.name);
259
263
  if (!durationCheck.valid) {
260
264
  URL.revokeObjectURL(objectUrl);
261
265
  avisos.push({
@@ -266,9 +270,16 @@ export const useDigitalAssets = ({
266
270
  });
267
271
  continue;
268
272
  }
269
- asset.duration = duration;
273
+ if (duration === null) {
274
+ warning = warning ?? warnUnreadableVideo(file.name);
275
+ } else {
276
+ asset.duration = duration;
277
+ }
270
278
  }
271
279
 
280
+ if (warning) {
281
+ avisos.push({ level: "warning", name: file.name, message: warning });
282
+ }
272
283
  accepted.push(asset);
273
284
  count += 1;
274
285
  }