contentoh-components-library 21.6.49 → 21.6.50

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/.claude/launch.json +14 -0
  2. package/.env.test +1 -0
  3. package/dist/ai/prompts/attribute.prompt.js +28 -0
  4. package/dist/ai/prompts/description.prompt.js +33 -0
  5. package/dist/ai/schemas/attribute.schema.js +35 -0
  6. package/dist/ai/schemas/description.schema.js +27 -0
  7. package/dist/components/atoms/AssetPreview/AssetPreview.stories.js +118 -0
  8. package/dist/components/atoms/AssetPreview/index.js +150 -0
  9. package/dist/components/atoms/AssetPreview/styles.js +28 -0
  10. package/dist/components/atoms/IconFile/index.js +85 -2
  11. package/dist/components/atoms/IconFile/styles.js +1 -1
  12. package/dist/components/atoms/ProductImage/ProductImage.stories.js +70 -2
  13. package/dist/components/atoms/ProductImage/index.js +100 -27
  14. package/dist/components/atoms/ProductImage/styles.js +3 -1
  15. package/dist/components/molecules/EditionActiveImage/index.js +4 -2
  16. package/dist/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +147 -0
  17. package/dist/components/molecules/GalleryAssetElement/index.js +178 -0
  18. package/dist/components/molecules/GalleryAssetElement/styles.js +26 -0
  19. package/dist/components/molecules/GalleryElement/index.js +13 -4
  20. package/dist/components/molecules/GalleryHeader/index.js +96 -24
  21. package/dist/components/molecules/GalleryHeader/styles.js +4 -1
  22. package/dist/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +56 -0
  23. package/dist/components/molecules/GalleryTrayTabs/index.js +60 -0
  24. package/dist/components/molecules/GalleryTrayTabs/styles.js +20 -0
  25. package/dist/components/molecules/ImageSelector/index.js +1 -0
  26. package/dist/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +171 -0
  27. package/dist/components/organisms/DigitalAssetsTray/index.js +211 -0
  28. package/dist/components/organisms/DigitalAssetsTray/styles.js +22 -0
  29. package/dist/components/organisms/ImagePreviewer/index.js +1 -0
  30. package/dist/components/organisms/ProductImageModal/index.js +3 -2
  31. package/dist/components/organisms/PropagateChangesModal/index.js +12 -1
  32. package/dist/components/organisms/PropagateChangesModal/non-propagable.js +45 -0
  33. package/dist/components/pages/ProviderProductEdition/index.js +11 -1
  34. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.context.js +330 -15
  35. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +12 -2
  36. package/dist/components/pages/RetailerProductEdition/index.js +605 -414
  37. package/dist/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +1 -0
  38. package/dist/components/pages/RetailerProductEdition/stories/TextEditor.stories.js +45 -145
  39. package/dist/global-files/data.js +16 -3
  40. package/dist/global-files/digital-assets/assetTypes.js +480 -0
  41. package/dist/global-files/digital-assets/assets.actions.js +64 -0
  42. package/dist/global-files/digital-assets/assets.adapter.js +304 -0
  43. package/dist/global-files/digital-assets/assets.mock.js +136 -0
  44. package/dist/global-files/digital-assets/assets.reducer.js +220 -0
  45. package/dist/global-files/digital-assets/uploadAssets.js +179 -0
  46. package/dist/global-files/digital-assets/useDigitalAssets.js +522 -0
  47. package/dist/index.js +142 -90
  48. package/docs/activos-digitales.md +273 -0
  49. package/package.json +1 -1
  50. package/src/components/atoms/AssetPreview/AssetPreview.stories.js +78 -0
  51. package/src/components/atoms/AssetPreview/index.js +119 -0
  52. package/src/components/atoms/AssetPreview/styles.js +190 -0
  53. package/src/components/atoms/IconFile/index.js +28 -1
  54. package/src/components/atoms/IconFile/styles.js +8 -0
  55. package/src/components/atoms/ProductImage/ProductImage.stories.js +38 -0
  56. package/src/components/atoms/ProductImage/index.js +95 -20
  57. package/src/components/atoms/ProductImage/styles.js +92 -7
  58. package/src/components/molecules/EditionActiveImage/index.js +2 -2
  59. package/src/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +102 -0
  60. package/src/components/molecules/GalleryAssetElement/index.js +162 -0
  61. package/src/components/molecules/GalleryAssetElement/styles.js +159 -0
  62. package/src/components/molecules/GalleryElement/index.js +9 -0
  63. package/src/components/molecules/GalleryHeader/index.js +65 -13
  64. package/src/components/molecules/GalleryHeader/styles.js +2 -1
  65. package/src/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +32 -0
  66. package/src/components/molecules/GalleryTrayTabs/index.js +41 -0
  67. package/src/components/molecules/GalleryTrayTabs/styles.js +52 -0
  68. package/src/components/molecules/ImageSelector/index.js +1 -0
  69. package/src/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +101 -0
  70. package/src/components/organisms/DigitalAssetsTray/index.js +189 -0
  71. package/src/components/organisms/DigitalAssetsTray/styles.js +183 -0
  72. package/src/components/organisms/ImagePreviewer/index.js +1 -0
  73. package/src/components/organisms/ProductImageModal/index.js +1 -0
  74. package/src/components/organisms/PropagateChangesModal/index.js +11 -1
  75. package/src/components/organisms/PropagateChangesModal/non-propagable.js +29 -0
  76. package/src/components/pages/ProviderProductEdition/index.js +10 -1
  77. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.context.jsx +201 -0
  78. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +12 -0
  79. package/src/components/pages/RetailerProductEdition/index.js +366 -140
  80. package/src/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +1 -0
  81. package/src/components/pages/RetailerProductEdition/stories/TextEditor.stories.js +51 -104
  82. package/src/global-files/data.js +17 -2
  83. package/src/global-files/digital-assets/assetTypes.js +419 -0
  84. package/src/global-files/digital-assets/assets.actions.js +50 -0
  85. package/src/global-files/digital-assets/assets.adapter.js +229 -0
  86. package/src/global-files/digital-assets/assets.mock.js +122 -0
  87. package/src/global-files/digital-assets/assets.reducer.js +197 -0
  88. package/src/global-files/digital-assets/uploadAssets.js +109 -0
  89. package/src/global-files/digital-assets/useDigitalAssets.js +329 -0
  90. package/src/index.js +4 -0
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
 
3
3
  import axios from "axios";
4
4
  import { useDropzone } from "react-dropzone";
@@ -30,6 +30,8 @@ import {
30
30
  getServicesData,
31
31
  } from "../../../global-files/data";
32
32
  import { useCloseModal } from "../../../global-files/customHooks";
33
+ import { useDigitalAssets } from "../../../global-files/digital-assets/useDigitalAssets";
34
+ import { countUnsavedAssets } from "../../../global-files/digital-assets/assets.reducer";
33
35
 
34
36
  import { Button } from "../../atoms/GeneralButton";
35
37
  import { GenericModal } from "../../atoms/GenericModal";
@@ -39,12 +41,14 @@ import { ButtonV2 } from "../../atoms/ButtonV2";
39
41
 
40
42
  import { GalleryElement } from "../../molecules/GalleryElement";
41
43
  import { GalleryHeader } from "../../molecules/GalleryHeader";
44
+ import { GalleryTrayTabs, TRAYS } from "../../molecules/GalleryTrayTabs";
42
45
  import { HeaderTop } from "../../molecules/HeaderTop";
43
46
  import { TagAndInput } from "../../molecules/TagAndInput/index";
44
47
 
45
48
  import { FullProductNameHeader } from "../../organisms/FullProductNameHeader";
46
49
  import { FullTabsMenu } from "../../organisms/FullTabsMenu";
47
50
  import { ImageDataTable } from "../../organisms/ImageDataTable";
51
+ import { DigitalAssetsTray } from "../../organisms/DigitalAssetsTray";
48
52
  import { ImagePreviewer } from "../../organisms/ImagePreviewer";
49
53
  import { InputGroup } from "../../organisms/InputGroup";
50
54
  import { ProductImageModal } from "../../organisms/ProductImageModal";
@@ -54,6 +58,7 @@ import { LinkVariantsModal } from "../../organisms/LinkVariantsModal";
54
58
  import { UnlinkVariantModal } from "../../organisms/UnlinkVariantModal";
55
59
  import { VariantGroupModal } from "../../organisms/VariantGroupModal";
56
60
  import { PropagateChangesModal } from "../../organisms/PropagateChangesModal";
61
+ import { soloPropagables } from "../../organisms/PropagateChangesModal/non-propagable";
57
62
  import { readPayload } from "../../organisms/LinkVariantsModal/api";
58
63
 
59
64
  import successIcon from "../../../assets/images/genericModal/genericModalCheck.svg";
@@ -100,62 +105,19 @@ function obtenerDuracionVideoBase64(base64Data) {
100
105
  });
101
106
  }
102
107
 
103
- function convertirVideoABase64(url) {
104
- return new Promise((resolve, reject) => {
105
- const xhr = new XMLHttpRequest();
106
- xhr.open("GET", url, true);
107
- xhr.responseType = "blob";
108
- xhr.onload = function () {
109
- if (xhr.status === 200) {
110
- const blob = xhr.response;
111
- const reader = new FileReader();
112
- reader.onloadend = function () {
113
- resolve(reader.result);
114
- };
115
- reader.readAsDataURL(blob);
116
- } else {
117
- reject(new Error("Error al cargar el video"));
118
- }
119
- };
120
- xhr.onerror = function () {
121
- reject(new Error("Error de red al cargar el video"));
122
- };
123
- xhr.send();
124
- });
125
- }
126
-
127
- // Para videos ya guardados, `getImage` deja en `src` una URL del
128
- // redimensionador de imágenes que no sirve para mp4. Descargamos el video
129
- // desde S3 (srcDB) y generamos un thumbnail JPEG para mostrarlo en la galería.
130
- async function regenerateVideoThumbnails(values) {
131
- if (!Array.isArray(values)) return values;
132
- return Promise.all(
133
- values.map(async (value) => {
134
- if (value?.ext !== "mp4" || !value?.srcDB) return value;
135
- try {
136
- const base64 = await convertirVideoABase64(
137
- "https://" +
138
- process.env.REACT_APP_IMAGES_BUCKET +
139
- ".s3.amazonaws.com/" +
140
- value.srcDB,
141
- );
142
- const videoSrc = "data:video/mp4;base64," + base64.split(",")[1];
143
- const thumbnail = await generateThumbnail(videoSrc, 2);
144
- return { ...value, src: thumbnail };
145
- } catch (error) {
146
- console.error("Error al generar thumbnail del video:", error);
147
- return value;
148
- }
149
- }),
150
- );
151
- }
152
-
153
108
  const RetailerProductEditionView = ({
154
109
  tabsSections,
155
110
  productSelected = {},
156
111
  user = {},
157
112
  token,
158
113
  location,
114
+ /*
115
+ Activos digitales (360, video, gif, pdf). Apagado por omisión: la interfaz
116
+ ya está, pero el backend no existe todavía, así que la bandeja solo puede
117
+ mostrar lo que el usuario acaba de soltar y nada se guarda. La aplicación
118
+ que consuma la librería lo enciende cuando el backend esté listo.
119
+ */
120
+ enableDigitalAssets = false,
159
121
  }) => {
160
122
  const {
161
123
  state,
@@ -164,6 +126,8 @@ const RetailerProductEditionView = ({
164
126
  saveDescriptions,
165
127
  updateImages,
166
128
  deleteImages,
129
+ saveAssets,
130
+ registerUploadedAssets,
167
131
  handleOnDownloadImages,
168
132
  createComment,
169
133
  } = useProviderProductEdition();
@@ -302,6 +266,54 @@ const RetailerProductEditionView = ({
302
266
 
303
267
  const [openChangeStatusModal, setOpenChangeStatusModal] = useState(false);
304
268
 
269
+ /*
270
+ Bandeja de activos digitales. La lista vive en el reducer de la ficha
271
+ (`state.assets_values`), igual que las imágenes, para que el guardado del
272
+ contexto pueda leerla. El hook solo aporta validación y ciclo de vida.
273
+
274
+ Sigue vacía porque todavía no hay de dónde leerla: cuando exista el endpoint
275
+ se llama a `replaceAll` desde `loadData` y el resto de la pantalla no cambia.
276
+ */
277
+ const [assetNotices, setAssetNotices] = useState([]);
278
+ const digitalAssets = useDigitalAssets({
279
+ assets: state.assets_values,
280
+ dispatch,
281
+ notices: assetNotices,
282
+ setNotices: setAssetNotices,
283
+ // Con los dos, la subida a S3 arranca sola al soltar los archivos. Sin
284
+ // ellos —en Storybook, por ejemplo— la bandeja funciona igual pero no sube.
285
+ articleId: state.product?.id_article ?? null,
286
+ token,
287
+ // Al terminar la subida el activo queda registrado solo, sin esperar al
288
+ // botón de guardar: con la barra llena, nadie supone que falta un paso.
289
+ onUploaded: (uploaded) => registerUploadedAssets(token, uploaded),
290
+ });
291
+ const [activeTray, setActiveTray] = useState(TRAYS.IMAGES);
292
+ const currentTray = enableDigitalAssets ? activeTray : TRAYS.IMAGES;
293
+
294
+ /*
295
+ De qué artículo se cargaron ya los activos.
296
+
297
+ `loadData` se vuelve a ejecutar cada vez que cambia la cadena activa, pero los
298
+ activos son generales: no dependen de la cadena ni de la versión, así que la
299
+ respuesta sería idéntica. Volver a cargarlos borraría los archivos que el
300
+ usuario acaba de soltar y todavía no guarda —sin aviso y sin forma de
301
+ recuperarlos—. Por eso se cargan una sola vez por artículo.
302
+ */
303
+ const assetsLoadedFor = useRef(null);
304
+
305
+ const handleOnSetSelectedAssets = (assets) =>
306
+ dispatch({
307
+ type: "SET_SELECTED_ASSETS",
308
+ payload: assets,
309
+ });
310
+
311
+ const handleOnSetAllAssetsChecked = (isChecked) =>
312
+ dispatch({
313
+ type: "TOGGLE_CHECK_ALL_ASSETS",
314
+ payload: isChecked,
315
+ });
316
+
305
317
  const thumbs = () => {
306
318
  const imageInputs = state.images_values?.inputs?.map((e) => ({
307
319
  value: e?.id,
@@ -344,6 +356,7 @@ const RetailerProductEditionView = ({
344
356
  updateApprovedInputsImages={(item) =>
345
357
  console.log("updateApprovedInputsImages", item)
346
358
  }
359
+ onSelect={() => handleOnChangeCurrentImage(index)}
347
360
  />
348
361
  ));
349
362
  };
@@ -610,12 +623,6 @@ const RetailerProductEditionView = ({
610
623
  );
611
624
  });
612
625
 
613
- // Regenerar thumbnails de videos guardados para que se muestren.
614
- const orderedValuesWithThumbs =
615
- await regenerateVideoThumbnails(orderedValues);
616
- const filteredValuesWithThumbs =
617
- await regenerateVideoThumbnails(filteredValues);
618
-
619
626
  // Dispatch consolidado
620
627
  dispatch({
621
628
  type: "SET_SERVICES",
@@ -624,7 +631,7 @@ const RetailerProductEditionView = ({
624
631
  descriptions: services[1],
625
632
  images: {
626
633
  ...services[2],
627
- values: orderedValuesWithThumbs,
634
+ values: orderedValues,
628
635
  },
629
636
  },
630
637
  });
@@ -641,7 +648,12 @@ const RetailerProductEditionView = ({
641
648
  // Setear inputs del retailer activo directamente (evita cascada)
642
649
  dispatch({
643
650
  type: "SET_DATASHEETS_INPUTS",
644
- payload: [datasheetsActiveRetailer, services[0].inputs],
651
+ // `services[0]?` por lo mismo que el fallback de arriba: la lectura de
652
+ // fichas puede volver sin datos para esa cadena y categoría. Arriba ya
653
+ // se contemplaba; aquí no, y como este dispatch va ANTES de
654
+ // SET_IMAGES_VALUES, reventar aquí dejaba la ficha cargando para
655
+ // siempre — con las imágenes ya traídas y sin pintar.
656
+ payload: [datasheetsActiveRetailer, services[0]?.inputs ?? {}],
645
657
  });
646
658
 
647
659
  dispatch({
@@ -653,10 +665,23 @@ const RetailerProductEditionView = ({
653
665
  type: "SET_IMAGES_VALUES",
654
666
  payload: {
655
667
  ...services[2],
656
- values: filteredValuesWithThumbs,
668
+ values: filteredValues,
657
669
  },
658
670
  });
659
671
 
672
+ /*
673
+ Activos digitales. `services[2].assets` todavía no existe en la
674
+ respuesta del backend: hasta que exista, esto no hace nada, y el día que
675
+ llegue la bandeja se llena sola sin tocar nada más.
676
+ */
677
+ if (
678
+ Array.isArray(services[2].assets) &&
679
+ assetsLoadedFor.current !== id_article
680
+ ) {
681
+ assetsLoadedFor.current = id_article;
682
+ digitalAssets.replaceAll(services[2].assets);
683
+ }
684
+
660
685
  // Calcular campos requeridos sin valor
661
686
  dispatch({
662
687
  type: "SET_MISSING_REQUIRED_FIELDS",
@@ -802,7 +827,8 @@ const RetailerProductEditionView = ({
802
827
  const canEvaluateAsAuditor =
803
828
  state.services_data.every((srv) =>
804
829
  ["AC", "RP", "RCA"].includes(srv?.status?.replace(/.*\//, "")),
805
- ) && ([1, 6].includes(user.id_role) || isAuditorAssigned());
830
+ ) &&
831
+ ([1, 6].includes(user.id_role) || isAuditorAssigned());
806
832
 
807
833
  return canEvaluateAsFacilitator || canEvaluateAsAuditor;
808
834
  };
@@ -823,7 +849,29 @@ const RetailerProductEditionView = ({
823
849
  return currentStatus;
824
850
  };
825
851
 
852
+ /*
853
+ Guarda los activos y recarga si algo se escribió.
854
+
855
+ La recarga no es opcional: un activo recién creado sigue en memoria con su
856
+ id temporal y sin el id de la base. Sin volver a leer, el siguiente guardado
857
+ lo mandaría otra vez como nuevo y se duplicaría. Poner el ref en `null` es
858
+ lo que permite que `loadData` lo vuelva a traer —normalmente los activos se
859
+ cargan una sola vez por artículo—.
860
+ */
861
+ const handleOnSaveAssets = async () => {
862
+ const guardado = await saveAssets(token);
863
+ if (!guardado) return;
864
+
865
+ assetsLoadedFor.current = null;
866
+ await loadData(false);
867
+ };
868
+
826
869
  const handleOnClickSaveImages = async () => {
870
+ if (currentTray === TRAYS.ASSETS) {
871
+ await handleOnSaveAssets();
872
+ return;
873
+ }
874
+
827
875
  if (state.product?.services?.images === 1) {
828
876
  /**
829
877
  * `updateImages` y NO `saveImageAttrs`.
@@ -861,7 +909,7 @@ const RetailerProductEditionView = ({
861
909
  const res = await axios.post(
862
910
  `${process.env.REACT_APP_VARIANTS_ENDPOINT}/family`,
863
911
  { articleId: state.product?.id_article },
864
- { headers: { Authorization: token } }
912
+ { headers: { Authorization: token } },
865
913
  );
866
914
  const data = readPayload(res);
867
915
  setFamily(data);
@@ -937,9 +985,9 @@ const RetailerProductEditionView = ({
937
985
  result.mode === "self"
938
986
  ? { ...state.product, parentArticleId: null, variantsCount: 0 }
939
987
  : {
940
- ...state.product,
941
- variantsCount: Math.max((state.product?.variantsCount ?? 1) - 1, 0),
942
- };
988
+ ...state.product,
989
+ variantsCount: Math.max((state.product?.variantsCount ?? 1) - 1, 0),
990
+ };
943
991
 
944
992
  dispatch({ type: "SET_PRODUCT", payload: updatedProduct });
945
993
 
@@ -948,7 +996,7 @@ const RetailerProductEditionView = ({
948
996
  // aplanada de esta pantalla lo dejaría mutilado en el siguiente F5.
949
997
  try {
950
998
  const guardado = JSON.parse(
951
- sessionStorage.getItem("productSelected") ?? "null"
999
+ sessionStorage.getItem("productSelected") ?? "null",
952
1000
  );
953
1001
 
954
1002
  if (guardado) {
@@ -958,7 +1006,7 @@ const RetailerProductEditionView = ({
958
1006
  ...guardado,
959
1007
  variantsCount: updatedProduct.variantsCount,
960
1008
  parentArticleId: updatedProduct.parentArticleId ?? null,
961
- })
1009
+ }),
962
1010
  );
963
1011
  }
964
1012
  } catch (error) {
@@ -999,7 +1047,7 @@ const RetailerProductEditionView = ({
999
1047
  const res = await axios.post(
1000
1048
  `${process.env.REACT_APP_VARIANTS_ENDPOINT}/group`,
1001
1049
  { articleId: state.product?.id_article },
1002
- { headers: { Authorization: token } }
1050
+ { headers: { Authorization: token } },
1003
1051
  );
1004
1052
  const data = readPayload(res);
1005
1053
  setVariantGroup(data);
@@ -1022,7 +1070,7 @@ const RetailerProductEditionView = ({
1022
1070
  const res = await axios.post(
1023
1071
  `${process.env.REACT_APP_VARIANTS_ENDPOINT}/leave`,
1024
1072
  { articleId: state.product?.id_article },
1025
- { headers: { Authorization: token } }
1073
+ { headers: { Authorization: token } },
1026
1074
  );
1027
1075
  setVariantGroup(readPayload(res));
1028
1076
  setShowGroup(false);
@@ -1057,7 +1105,7 @@ const RetailerProductEditionView = ({
1057
1105
  {
1058
1106
  params: { filterByIdArticle: articleId, includeFilters: false },
1059
1107
  headers: { Authorization: token },
1060
- }
1108
+ },
1061
1109
  );
1062
1110
 
1063
1111
  // read-order-services responde { total, data: [...] }. La vista trae una
@@ -1065,12 +1113,12 @@ const RetailerProductEditionView = ({
1065
1113
  // ordenadas por order_id DESC y la primera es la orden más reciente.
1066
1114
  const { data } = readPayload(res) ?? {};
1067
1115
  const encontrado = (data ?? []).find(
1068
- (item) => Number(item?.article?.id_article) === Number(articleId)
1116
+ (item) => Number(item?.article?.id_article) === Number(articleId),
1069
1117
  );
1070
1118
 
1071
1119
  if (!encontrado) {
1072
1120
  setAvisoFamilia(
1073
- "Ese producto no está en ninguna orden de trabajo, así que no se puede abrir desde aquí."
1121
+ "Ese producto no está en ninguna orden de trabajo, así que no se puede abrir desde aquí.",
1074
1122
  );
1075
1123
  return;
1076
1124
  }
@@ -1083,7 +1131,7 @@ const RetailerProductEditionView = ({
1083
1131
  ArticleId: encontrado.article.id_article,
1084
1132
  categoryId: encontrado.article.id_article,
1085
1133
  product: [encontrado],
1086
- })
1134
+ }),
1087
1135
  );
1088
1136
 
1089
1137
  window.location.assign("/EditProducts");
@@ -1108,7 +1156,8 @@ const RetailerProductEditionView = ({
1108
1156
  ? {
1109
1157
  ...state.product,
1110
1158
  variantsCount:
1111
- (state.product?.variantsCount ?? 0) + (result.linked ?? []).length,
1159
+ (state.product?.variantsCount ?? 0) +
1160
+ (result.linked ?? []).length,
1112
1161
  }
1113
1162
  : {
1114
1163
  ...state.product,
@@ -1132,7 +1181,7 @@ const RetailerProductEditionView = ({
1132
1181
  */
1133
1182
  try {
1134
1183
  const guardado = JSON.parse(
1135
- sessionStorage.getItem("productSelected") ?? "null"
1184
+ sessionStorage.getItem("productSelected") ?? "null",
1136
1185
  );
1137
1186
 
1138
1187
  if (guardado) {
@@ -1142,7 +1191,7 @@ const RetailerProductEditionView = ({
1142
1191
  ...guardado,
1143
1192
  variantsCount: updatedProduct.variantsCount,
1144
1193
  parentArticleId: updatedProduct.parentArticleId ?? null,
1145
- })
1194
+ }),
1146
1195
  );
1147
1196
  }
1148
1197
  } catch (error) {
@@ -1184,7 +1233,10 @@ const RetailerProductEditionView = ({
1184
1233
  `rel_article_description` y la ficha técnica en `article_data`: el endpoint
1185
1234
  recibe una lista o la otra.
1186
1235
  */
1187
- const handleOnRestoreInheritance = async (attributeId, kind = "attribute") => {
1236
+ const handleOnRestoreInheritance = async (
1237
+ attributeId,
1238
+ kind = "attribute",
1239
+ ) => {
1188
1240
  if (!attributeId || !state.product?.id_article) return;
1189
1241
 
1190
1242
  const esDescripcion = kind === "description";
@@ -1198,7 +1250,7 @@ const RetailerProductEditionView = ({
1198
1250
  ? { descriptions: [Number(attributeId)] }
1199
1251
  : { attributes: [Number(attributeId)] }),
1200
1252
  },
1201
- { headers: { Authorization: token } }
1253
+ { headers: { Authorization: token } },
1202
1254
  );
1203
1255
 
1204
1256
  const data = readPayload(res);
@@ -1236,7 +1288,7 @@ const RetailerProductEditionView = ({
1236
1288
  const retailerId = state.active_retailer?.id_retailer;
1237
1289
  const inputs = state.services?.datasheets?.inputs ?? {};
1238
1290
 
1239
- return (state.updated_datasheets_inputs ?? []).map((item) => {
1291
+ const cambios = (state.updated_datasheets_inputs ?? []).map((item) => {
1240
1292
  const input = inputs[`${item.attributeId}_${retailerId}`];
1241
1293
  return {
1242
1294
  id: Number(item.attributeId),
@@ -1244,6 +1296,14 @@ const RetailerProductEditionView = ({
1244
1296
  value: item.value,
1245
1297
  };
1246
1298
  });
1299
+
1300
+ /*
1301
+ UPC y SKU se quitan aquí y no dentro del diálogo porque esta lista también
1302
+ decide SI el diálogo se abre. Editar solo el UPC no tiene nada que
1303
+ propagar, así que preguntarlo sería pedirle al usuario una decisión sobre
1304
+ un conjunto vacío.
1305
+ */
1306
+ return soloPropagables(cambios);
1247
1307
  };
1248
1308
 
1249
1309
  const handleOnClickSave = async () => {
@@ -1325,7 +1385,7 @@ const RetailerProductEditionView = ({
1325
1385
  puede copiar igual. Si no se miraran, guardar no ofrecería nada.
1326
1386
  */
1327
1387
  const companeras = (variantGroup?.members ?? []).filter(
1328
- (m) => !m.isCurrent
1388
+ (m) => !m.isCurrent,
1329
1389
  );
1330
1390
 
1331
1391
  if (hijos.length > 0 || companeras.length > 0) {
@@ -1337,6 +1397,16 @@ const RetailerProductEditionView = ({
1337
1397
  break;
1338
1398
  }
1339
1399
  case "Imágenes":
1400
+ /*
1401
+ Se guarda la bandeja que el usuario tiene a la vista. Guardar las dos
1402
+ de golpe haría que, estando en activos, `updateImages` enviara también
1403
+ los cambios de imágenes que el usuario quizá no dio por terminados —y
1404
+ al revés—. El botón guarda lo que se está viendo.
1405
+ */
1406
+ if (currentTray === TRAYS.ASSETS) {
1407
+ await handleOnSaveAssets();
1408
+ return;
1409
+ }
1340
1410
  await updateImages(token);
1341
1411
  break;
1342
1412
  default:
@@ -1360,7 +1430,98 @@ const RetailerProductEditionView = ({
1360
1430
  });
1361
1431
  };
1362
1432
 
1433
+ /*
1434
+ Descarga de activos: los seleccionados, o todos si no hay selección — el
1435
+ mismo criterio que usa la descarga de imágenes.
1436
+
1437
+ Solo se descarga lo que ya está en S3. Un activo recién soltado vive como
1438
+ referencia al archivo del disco del usuario: "descargarlo" le devolvería el
1439
+ archivo que él mismo acaba de elegir, lo cual no tiene sentido.
1440
+ */
1441
+ const handleOnDownloadAssets = () => {
1442
+ const seleccion = state.selected_assets.length
1443
+ ? state.selected_assets
1444
+ : state.assets_values;
1445
+
1446
+ const descargables = seleccion.filter((asset) => asset.id && asset.url);
1447
+
1448
+ if (descargables.length === 0) {
1449
+ dispatch({
1450
+ type: "SET_MODAL",
1451
+ payload: {
1452
+ show: true,
1453
+ title: "Nada que descargar",
1454
+ message:
1455
+ "Los activos seleccionados todavía no se han guardado. Guárdalos primero.",
1456
+ image: warningIcon,
1457
+ },
1458
+ });
1459
+ return;
1460
+ }
1461
+
1462
+ descargables.forEach((asset) => saveAs(asset.url, asset.name));
1463
+ };
1464
+
1465
+ /*
1466
+ Borrado en lote de activos.
1467
+
1468
+ A diferencia de las imágenes, que se borran contra el backend en el momento,
1469
+ los activos se quitan de la bandeja y su baja viaja en el mismo PUT del
1470
+ guardado (`deleteAssets`). Es lo que permite al usuario deshacerse de un
1471
+ borrado por error: basta con no guardar. El mensaje lo dice, para que nadie
1472
+ se quede pensando que ya se aplicó.
1473
+ */
1474
+ const handleOnAskToDeleteAssets = () => {
1475
+ if (state.selected_assets.length === 0) {
1476
+ dispatch({
1477
+ type: "SET_MODAL",
1478
+ payload: {
1479
+ show: true,
1480
+ title: "Eliminar activos digitales",
1481
+ message: "No has seleccionado ningún activo para eliminar.",
1482
+ image: warningIcon,
1483
+ },
1484
+ });
1485
+ return;
1486
+ }
1487
+
1488
+ const seleccionados = state.selected_assets;
1489
+
1490
+ dispatch({
1491
+ type: "SET_MODAL",
1492
+ payload: {
1493
+ show: true,
1494
+ title: "Eliminar activos digitales",
1495
+ message: `Se quitarán ${seleccionados.length} activo(s) de la bandeja. El borrado se aplica al guardar.`,
1496
+ image: warningIcon,
1497
+ buttons: [
1498
+ {
1499
+ text: "Cancelar",
1500
+ buttonType: "general-white-button",
1501
+ action: () =>
1502
+ dispatch({ type: "SET_MODAL", payload: { show: false } }),
1503
+ },
1504
+ {
1505
+ text: "Eliminar",
1506
+ buttonType: "general-button-default",
1507
+ action: () => {
1508
+ dispatch({ type: "SET_MODAL", payload: { show: false } });
1509
+ seleccionados.forEach((asset) =>
1510
+ digitalAssets.removeAsset(asset),
1511
+ );
1512
+ },
1513
+ },
1514
+ ],
1515
+ },
1516
+ });
1517
+ };
1518
+
1363
1519
  const handleOnAskToDeleteImages = () => {
1520
+ if (currentTray === TRAYS.ASSETS) {
1521
+ handleOnAskToDeleteAssets();
1522
+ return;
1523
+ }
1524
+
1364
1525
  if (state.selected_images.length === 0) {
1365
1526
  dispatch({
1366
1527
  type: "SET_MODAL",
@@ -2074,7 +2235,25 @@ const RetailerProductEditionView = ({
2074
2235
  </Container>
2075
2236
  );
2076
2237
 
2077
- if (state.loading || !state.services || !state.product)
2238
+ /*
2239
+ `images_values` entra al guard junto a `services` y `product`.
2240
+
2241
+ React 17 no agrupa los dispatch que van después de un `await`: cada uno
2242
+ re-renderiza al instante. En `loadData` el orden es SET_SERVICES →
2243
+ …_INPUTS → SET_IMAGES_VALUES, así que había renders intermedios con
2244
+ `services` ya puesto —el guard los dejaba pasar— y el resto todavía en null.
2245
+ El cuerpo de abajo lee `datasheets_inputs[1]`, `descriptions_inputs[0]` y
2246
+ `images_values.values` sin protección, y tumbaba la pantalla entera.
2247
+
2248
+ Se comprueba `images_values` y no cada uno: es el ÚLTIMO que `loadData`
2249
+ despacha, así que si ya está, los anteriores también.
2250
+ */
2251
+ if (
2252
+ state.loading ||
2253
+ !state.services ||
2254
+ !state.product ||
2255
+ !state.images_values
2256
+ )
2078
2257
  return (
2079
2258
  <>
2080
2259
  <ProductEditionSkeleton />
@@ -2094,7 +2273,7 @@ const RetailerProductEditionView = ({
2094
2273
  changedAttributes={pendingPropagation.cambios}
2095
2274
  variants={(family?.variants ?? []).filter((v) => !v.isCurrent)}
2096
2275
  groupMembers={(variantGroup?.members ?? []).filter(
2097
- (m) => !m.isCurrent
2276
+ (m) => !m.isCurrent,
2098
2277
  )}
2099
2278
  groupCode={variantGroup?.group ?? null}
2100
2279
  saving={state.saving}
@@ -2311,8 +2490,18 @@ const RetailerProductEditionView = ({
2311
2490
  <div className="data-container">
2312
2491
  <div className="image-data-panel">
2313
2492
  <ImagePreviewer
2493
+ /*
2494
+ `images_values` puede ser null en pleno render.
2495
+
2496
+ React 17 no agrupa los dispatch que van después de un `await`:
2497
+ cada uno re-renderiza al instante. En `loadData`, `SET_SERVICES`
2498
+ se despacha antes que `SET_IMAGES_VALUES`, así que hay un render
2499
+ en el que `services` ya existe —y por eso el guard de arriba deja
2500
+ pasar— pero las imágenes todavía no. Sin las interrogaciones,
2501
+ ese render intermedio tumba la pantalla entera.
2502
+ */
2314
2503
  activeImage={
2315
- state.images_values.values[state.current_image] ?? {}
2504
+ state.images_values?.values?.[state.current_image] ?? {}
2316
2505
  }
2317
2506
  imagesArray={state.images_values || []}
2318
2507
  setActiveImage={handleOnChangeCurrentImage}
@@ -2618,7 +2807,13 @@ const RetailerProductEditionView = ({
2618
2807
  setShowVersionSelector={setShowVersionSelector}
2619
2808
  // setSelectedImages={setSelectedImages} prop pasada asi: FullTabsMenu -> TabsMenu Pero en TabsMenu no se espera ninguna prop setSelectedImages
2620
2809
  // actions
2621
- downloadImages={handleOnDownloadImages}
2810
+ // El botón actúa sobre la bandeja visible: en activos descargaba
2811
+ // las imágenes del producto, que no es lo que el usuario pidió.
2812
+ downloadImages={
2813
+ currentTray === TRAYS.ASSETS
2814
+ ? handleOnDownloadAssets
2815
+ : handleOnDownloadImages
2816
+ }
2622
2817
  askToDeleteImages={handleOnAskToDeleteImages}
2623
2818
  showSaveButton={isAuditorAssigned() || isUserAssignedToService()}
2624
2819
  onClickSave={handleOnClickSave}
@@ -2696,7 +2891,36 @@ const RetailerProductEditionView = ({
2696
2891
  {state.active_tab === "Imágenes" &&
2697
2892
  (state.product?.images_status !== "NS" ? (
2698
2893
  <>
2699
- {!imageLayout && (
2894
+ {enableDigitalAssets && (
2895
+ <GalleryTrayTabs
2896
+ activeTray={currentTray}
2897
+ setActiveTray={setActiveTray}
2898
+ imagesCount={
2899
+ state.images_values?.values?.length ?? 0
2900
+ }
2901
+ assetsCount={digitalAssets.assets.length}
2902
+ />
2903
+ )}
2904
+ {currentTray === TRAYS.ASSETS && (
2905
+ <section
2906
+ className="container"
2907
+ style={{ position: "relative" }}
2908
+ >
2909
+ <DigitalAssetsTray
2910
+ {...digitalAssets}
2911
+ unsaved={countUnsavedAssets(state)}
2912
+ gridLayout={imageLayout}
2913
+ readOnly={
2914
+ !isAuditorAssigned() &&
2915
+ !isUserAssignedToService()
2916
+ }
2917
+ selectedAssets={state.selected_assets}
2918
+ setSelectedAssets={handleOnSetSelectedAssets}
2919
+ setCheckAll={handleOnSetAllAssetsChecked}
2920
+ />
2921
+ </section>
2922
+ )}
2923
+ {currentTray === TRAYS.IMAGES && !imageLayout && (
2700
2924
  <GalleryHeader
2701
2925
  checkAll={state.all_image_checked} // Determina si el checkbox "Seleccionar todo" está marcado
2702
2926
  setCheckAll={handleOnSetCheckAll} // Toggler de true o false para el checkbox "Seleccionar todo"
@@ -2704,74 +2928,76 @@ const RetailerProductEditionView = ({
2704
2928
  // shotThd={shotThd} // No se usa?
2705
2929
  />
2706
2930
  )}
2707
- <section
2708
- className="container"
2709
- style={{ position: "relative" }}
2710
- >
2711
- <div
2712
- {...getRootProps({
2713
- className: `dropzone ${
2714
- isDragActive ? "drag-active" : ""
2715
- }`,
2716
- })}
2931
+ {currentTray === TRAYS.IMAGES && (
2932
+ <section
2933
+ className="container"
2934
+ style={{ position: "relative" }}
2717
2935
  >
2718
- <input {...getInputProps()} />
2719
- {isDragActive && (
2720
- <div className="drag-overlay">
2721
- <p>Suelta las imágenes aquí</p>
2722
- </div>
2723
- )}
2724
- <aside>{thumbs()}</aside>
2725
- </div>
2726
- {state.images_values?.values.length === 0 && (
2727
2936
  <div
2728
- style={{
2729
- position: "absolute",
2730
- top: "50%",
2731
- left: "50%",
2732
- transform: "translate(-50%, -50%)",
2733
- textAlign: "center",
2734
- padding: "40px",
2735
- width: "80%",
2736
- maxWidth: "500px",
2737
- fontFamily: "Arial, sans-serif",
2738
- }}
2937
+ {...getRootProps({
2938
+ className: `dropzone ${
2939
+ isDragActive ? "drag-active" : ""
2940
+ }`,
2941
+ })}
2739
2942
  >
2740
- <p
2943
+ <input {...getInputProps()} />
2944
+ {isDragActive && (
2945
+ <div className="drag-overlay">
2946
+ <p>Suelta las imágenes aquí</p>
2947
+ </div>
2948
+ )}
2949
+ <aside>{thumbs()}</aside>
2950
+ </div>
2951
+ {state.images_values?.values?.length === 0 && (
2952
+ <div
2741
2953
  style={{
2742
- fontSize: "18px",
2743
- color: "#666",
2744
- marginBottom: "16px",
2954
+ position: "absolute",
2955
+ top: "50%",
2956
+ left: "50%",
2957
+ transform: "translate(-50%, -50%)",
2958
+ textAlign: "center",
2959
+ padding: "40px",
2960
+ width: "80%",
2961
+ maxWidth: "500px",
2962
+ fontFamily: "Arial, sans-serif",
2745
2963
  }}
2746
2964
  >
2747
- Este producto no tiene imágenes
2748
- </p>
2749
- {isAuditorAssigned() ||
2750
- isUserAssignedToService() ? (
2751
2965
  <p
2752
2966
  style={{
2753
- fontSize: "14px",
2754
- color: "#999",
2755
- marginBottom: "20px",
2967
+ fontSize: "18px",
2968
+ color: "#666",
2969
+ marginBottom: "16px",
2756
2970
  }}
2757
2971
  >
2758
- Arrastra las imágenes aquí o{" "}
2759
- <span
2760
- onClick={open}
2972
+ Este producto no tiene imágenes
2973
+ </p>
2974
+ {isAuditorAssigned() ||
2975
+ isUserAssignedToService() ? (
2976
+ <p
2761
2977
  style={{
2762
- color: "#007bff",
2763
- cursor: "pointer",
2764
- textDecoration: "underline",
2978
+ fontSize: "14px",
2979
+ color: "#999",
2980
+ marginBottom: "20px",
2765
2981
  }}
2766
2982
  >
2767
- haz clic para abrir el explorador de
2768
- archivos
2769
- </span>
2770
- </p>
2771
- ) : null}
2772
- </div>
2773
- )}
2774
- </section>
2983
+ Arrastra las imágenes aquí o{" "}
2984
+ <span
2985
+ onClick={open}
2986
+ style={{
2987
+ color: "#007bff",
2988
+ cursor: "pointer",
2989
+ textDecoration: "underline",
2990
+ }}
2991
+ >
2992
+ haz clic para abrir el explorador de
2993
+ archivos
2994
+ </span>
2995
+ </p>
2996
+ ) : null}
2997
+ </div>
2998
+ )}
2999
+ </section>
3000
+ )}
2775
3001
  </>
2776
3002
  ) : (
2777
3003
  <ScreenHeader