contentoh-components-library 21.6.50 → 21.6.51

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 (86) 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/pages/ProviderProductEdition/index.js +78 -53
  15. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.context.js +15 -330
  16. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +2 -12
  17. package/dist/components/pages/RetailerProductEdition/index.js +445 -603
  18. package/dist/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +0 -1
  19. package/dist/components/pages/RetailerProductEdition/stories/TextEditor.stories.js +145 -45
  20. package/dist/global-files/data.js +3 -16
  21. package/dist/index.js +90 -142
  22. package/package.json +1 -1
  23. package/src/components/atoms/IconFile/index.js +1 -28
  24. package/src/components/atoms/IconFile/styles.js +0 -8
  25. package/src/components/atoms/ProductImage/ProductImage.stories.js +0 -38
  26. package/src/components/atoms/ProductImage/index.js +20 -95
  27. package/src/components/atoms/ProductImage/styles.js +7 -92
  28. package/src/components/molecules/EditionActiveImage/index.js +2 -2
  29. package/src/components/molecules/GalleryElement/index.js +0 -9
  30. package/src/components/molecules/GalleryHeader/index.js +13 -65
  31. package/src/components/molecules/GalleryHeader/styles.js +1 -2
  32. package/src/components/molecules/ImageSelector/index.js +0 -1
  33. package/src/components/organisms/ImagePreviewer/index.js +0 -1
  34. package/src/components/organisms/ProductImageModal/index.js +0 -1
  35. package/src/components/pages/ProviderProductEdition/index.js +36 -3
  36. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.context.jsx +0 -201
  37. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +0 -12
  38. package/src/components/pages/RetailerProductEdition/index.js +179 -360
  39. package/src/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +0 -1
  40. package/src/components/pages/RetailerProductEdition/stories/TextEditor.stories.js +104 -51
  41. package/src/global-files/data.js +2 -17
  42. package/src/index.js +0 -4
  43. package/.claude/launch.json +0 -14
  44. package/dist/ai/prompts/attribute.prompt.js +0 -28
  45. package/dist/ai/prompts/description.prompt.js +0 -33
  46. package/dist/ai/schemas/attribute.schema.js +0 -35
  47. package/dist/ai/schemas/description.schema.js +0 -27
  48. package/dist/components/atoms/AssetPreview/AssetPreview.stories.js +0 -118
  49. package/dist/components/atoms/AssetPreview/index.js +0 -150
  50. package/dist/components/atoms/AssetPreview/styles.js +0 -28
  51. package/dist/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +0 -147
  52. package/dist/components/molecules/GalleryAssetElement/index.js +0 -178
  53. package/dist/components/molecules/GalleryAssetElement/styles.js +0 -26
  54. package/dist/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +0 -56
  55. package/dist/components/molecules/GalleryTrayTabs/index.js +0 -60
  56. package/dist/components/molecules/GalleryTrayTabs/styles.js +0 -20
  57. package/dist/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +0 -171
  58. package/dist/components/organisms/DigitalAssetsTray/index.js +0 -211
  59. package/dist/components/organisms/DigitalAssetsTray/styles.js +0 -22
  60. package/dist/global-files/digital-assets/assetTypes.js +0 -480
  61. package/dist/global-files/digital-assets/assets.actions.js +0 -64
  62. package/dist/global-files/digital-assets/assets.adapter.js +0 -304
  63. package/dist/global-files/digital-assets/assets.mock.js +0 -136
  64. package/dist/global-files/digital-assets/assets.reducer.js +0 -220
  65. package/dist/global-files/digital-assets/uploadAssets.js +0 -179
  66. package/dist/global-files/digital-assets/useDigitalAssets.js +0 -522
  67. package/docs/activos-digitales.md +0 -273
  68. package/src/components/atoms/AssetPreview/AssetPreview.stories.js +0 -78
  69. package/src/components/atoms/AssetPreview/index.js +0 -119
  70. package/src/components/atoms/AssetPreview/styles.js +0 -190
  71. package/src/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +0 -102
  72. package/src/components/molecules/GalleryAssetElement/index.js +0 -162
  73. package/src/components/molecules/GalleryAssetElement/styles.js +0 -159
  74. package/src/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +0 -32
  75. package/src/components/molecules/GalleryTrayTabs/index.js +0 -41
  76. package/src/components/molecules/GalleryTrayTabs/styles.js +0 -52
  77. package/src/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +0 -101
  78. package/src/components/organisms/DigitalAssetsTray/index.js +0 -189
  79. package/src/components/organisms/DigitalAssetsTray/styles.js +0 -183
  80. package/src/global-files/digital-assets/assetTypes.js +0 -419
  81. package/src/global-files/digital-assets/assets.actions.js +0 -50
  82. package/src/global-files/digital-assets/assets.adapter.js +0 -229
  83. package/src/global-files/digital-assets/assets.mock.js +0 -122
  84. package/src/global-files/digital-assets/assets.reducer.js +0 -197
  85. package/src/global-files/digital-assets/uploadAssets.js +0 -109
  86. package/src/global-files/digital-assets/useDigitalAssets.js +0 -329
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
 
3
3
  import axios from "axios";
4
4
  import { useDropzone } from "react-dropzone";
@@ -30,8 +30,6 @@ 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";
35
33
 
36
34
  import { Button } from "../../atoms/GeneralButton";
37
35
  import { GenericModal } from "../../atoms/GenericModal";
@@ -41,14 +39,12 @@ import { ButtonV2 } from "../../atoms/ButtonV2";
41
39
 
42
40
  import { GalleryElement } from "../../molecules/GalleryElement";
43
41
  import { GalleryHeader } from "../../molecules/GalleryHeader";
44
- import { GalleryTrayTabs, TRAYS } from "../../molecules/GalleryTrayTabs";
45
42
  import { HeaderTop } from "../../molecules/HeaderTop";
46
43
  import { TagAndInput } from "../../molecules/TagAndInput/index";
47
44
 
48
45
  import { FullProductNameHeader } from "../../organisms/FullProductNameHeader";
49
46
  import { FullTabsMenu } from "../../organisms/FullTabsMenu";
50
47
  import { ImageDataTable } from "../../organisms/ImageDataTable";
51
- import { DigitalAssetsTray } from "../../organisms/DigitalAssetsTray";
52
48
  import { ImagePreviewer } from "../../organisms/ImagePreviewer";
53
49
  import { InputGroup } from "../../organisms/InputGroup";
54
50
  import { ProductImageModal } from "../../organisms/ProductImageModal";
@@ -105,19 +101,62 @@ function obtenerDuracionVideoBase64(base64Data) {
105
101
  });
106
102
  }
107
103
 
104
+ function convertirVideoABase64(url) {
105
+ return new Promise((resolve, reject) => {
106
+ const xhr = new XMLHttpRequest();
107
+ xhr.open("GET", url, true);
108
+ xhr.responseType = "blob";
109
+ xhr.onload = function () {
110
+ if (xhr.status === 200) {
111
+ const blob = xhr.response;
112
+ const reader = new FileReader();
113
+ reader.onloadend = function () {
114
+ resolve(reader.result);
115
+ };
116
+ reader.readAsDataURL(blob);
117
+ } else {
118
+ reject(new Error("Error al cargar el video"));
119
+ }
120
+ };
121
+ xhr.onerror = function () {
122
+ reject(new Error("Error de red al cargar el video"));
123
+ };
124
+ xhr.send();
125
+ });
126
+ }
127
+
128
+ // Para videos ya guardados, `getImage` deja en `src` una URL del
129
+ // redimensionador de imágenes que no sirve para mp4. Descargamos el video
130
+ // desde S3 (srcDB) y generamos un thumbnail JPEG para mostrarlo en la galería.
131
+ async function regenerateVideoThumbnails(values) {
132
+ if (!Array.isArray(values)) return values;
133
+ return Promise.all(
134
+ values.map(async (value) => {
135
+ if (value?.ext !== "mp4" || !value?.srcDB) return value;
136
+ try {
137
+ const base64 = await convertirVideoABase64(
138
+ "https://" +
139
+ process.env.REACT_APP_IMAGES_BUCKET +
140
+ ".s3.amazonaws.com/" +
141
+ value.srcDB,
142
+ );
143
+ const videoSrc = "data:video/mp4;base64," + base64.split(",")[1];
144
+ const thumbnail = await generateThumbnail(videoSrc, 2);
145
+ return { ...value, src: thumbnail };
146
+ } catch (error) {
147
+ console.error("Error al generar thumbnail del video:", error);
148
+ return value;
149
+ }
150
+ }),
151
+ );
152
+ }
153
+
108
154
  const RetailerProductEditionView = ({
109
155
  tabsSections,
110
156
  productSelected = {},
111
157
  user = {},
112
158
  token,
113
159
  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,
121
160
  }) => {
122
161
  const {
123
162
  state,
@@ -126,8 +165,6 @@ const RetailerProductEditionView = ({
126
165
  saveDescriptions,
127
166
  updateImages,
128
167
  deleteImages,
129
- saveAssets,
130
- registerUploadedAssets,
131
168
  handleOnDownloadImages,
132
169
  createComment,
133
170
  } = useProviderProductEdition();
@@ -161,6 +198,7 @@ const RetailerProductEditionView = ({
161
198
  const [variantGroup, setVariantGroup] = useState(null);
162
199
  const [showGroup, setShowGroup] = useState(false);
163
200
  const [showGroupModal, setShowGroupModal] = useState(false);
201
+
164
202
  // Qué vínculo se está por romper, o null si no hay diálogo abierto. Guarda al
165
203
  // HIJO, que es quien lleva la relación y lo que pide la acción `unlink`.
166
204
  const [porDesvincular, setPorDesvincular] = useState(null);
@@ -266,54 +304,6 @@ const RetailerProductEditionView = ({
266
304
 
267
305
  const [openChangeStatusModal, setOpenChangeStatusModal] = useState(false);
268
306
 
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
-
317
307
  const thumbs = () => {
318
308
  const imageInputs = state.images_values?.inputs?.map((e) => ({
319
309
  value: e?.id,
@@ -356,7 +346,6 @@ const RetailerProductEditionView = ({
356
346
  updateApprovedInputsImages={(item) =>
357
347
  console.log("updateApprovedInputsImages", item)
358
348
  }
359
- onSelect={() => handleOnChangeCurrentImage(index)}
360
349
  />
361
350
  ));
362
351
  };
@@ -623,6 +612,12 @@ const RetailerProductEditionView = ({
623
612
  );
624
613
  });
625
614
 
615
+ // Regenerar thumbnails de videos guardados para que se muestren.
616
+ const orderedValuesWithThumbs =
617
+ await regenerateVideoThumbnails(orderedValues);
618
+ const filteredValuesWithThumbs =
619
+ await regenerateVideoThumbnails(filteredValues);
620
+
626
621
  // Dispatch consolidado
627
622
  dispatch({
628
623
  type: "SET_SERVICES",
@@ -631,7 +626,7 @@ const RetailerProductEditionView = ({
631
626
  descriptions: services[1],
632
627
  images: {
633
628
  ...services[2],
634
- values: orderedValues,
629
+ values: orderedValuesWithThumbs,
635
630
  },
636
631
  },
637
632
  });
@@ -648,12 +643,7 @@ const RetailerProductEditionView = ({
648
643
  // Setear inputs del retailer activo directamente (evita cascada)
649
644
  dispatch({
650
645
  type: "SET_DATASHEETS_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 ?? {}],
646
+ payload: [datasheetsActiveRetailer, services[0].inputs],
657
647
  });
658
648
 
659
649
  dispatch({
@@ -665,23 +655,10 @@ const RetailerProductEditionView = ({
665
655
  type: "SET_IMAGES_VALUES",
666
656
  payload: {
667
657
  ...services[2],
668
- values: filteredValues,
658
+ values: filteredValuesWithThumbs,
669
659
  },
670
660
  });
671
661
 
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
-
685
662
  // Calcular campos requeridos sin valor
686
663
  dispatch({
687
664
  type: "SET_MISSING_REQUIRED_FIELDS",
@@ -827,8 +804,7 @@ const RetailerProductEditionView = ({
827
804
  const canEvaluateAsAuditor =
828
805
  state.services_data.every((srv) =>
829
806
  ["AC", "RP", "RCA"].includes(srv?.status?.replace(/.*\//, "")),
830
- ) &&
831
- ([1, 6].includes(user.id_role) || isAuditorAssigned());
807
+ ) && ([1, 6].includes(user.id_role) || isAuditorAssigned());
832
808
 
833
809
  return canEvaluateAsFacilitator || canEvaluateAsAuditor;
834
810
  };
@@ -849,29 +825,7 @@ const RetailerProductEditionView = ({
849
825
  return currentStatus;
850
826
  };
851
827
 
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
-
869
828
  const handleOnClickSaveImages = async () => {
870
- if (currentTray === TRAYS.ASSETS) {
871
- await handleOnSaveAssets();
872
- return;
873
- }
874
-
875
829
  if (state.product?.services?.images === 1) {
876
830
  /**
877
831
  * `updateImages` y NO `saveImageAttrs`.
@@ -909,7 +863,7 @@ const RetailerProductEditionView = ({
909
863
  const res = await axios.post(
910
864
  `${process.env.REACT_APP_VARIANTS_ENDPOINT}/family`,
911
865
  { articleId: state.product?.id_article },
912
- { headers: { Authorization: token } },
866
+ { headers: { Authorization: token } }
913
867
  );
914
868
  const data = readPayload(res);
915
869
  setFamily(data);
@@ -949,6 +903,20 @@ const RetailerProductEditionView = ({
949
903
  ? !!state.product?.parentArticleId
950
904
  : !!family?.parent;
951
905
 
906
+ /*
907
+ Un hijo no se agrupa como variante (decisión de producto, 2026-09-25).
908
+
909
+ Agrupar declara «estos son el mismo producto en otra presentación», y un
910
+ hijo ya declaró de dónde deriva. El servidor lo rechaza con
911
+ VARIANT_GROUP_NOT_FOR_CHILD, así que ofrecer el botón sería ofrecer algo
912
+ que siempre falla.
913
+
914
+ Se apoya en `esHijoDeOtro` y no en `state.product` porque en esta pantalla
915
+ el producto sale de sessionStorage: la señal buena es la familia recién
916
+ consultada, con el producto solo como respaldo si esa consulta falló.
917
+ */
918
+ const puedeAgruparse = !esHijoDeOtro;
919
+
952
920
  /*
953
921
  Abre otro producto de la familia.
954
922
 
@@ -985,9 +953,9 @@ const RetailerProductEditionView = ({
985
953
  result.mode === "self"
986
954
  ? { ...state.product, parentArticleId: null, variantsCount: 0 }
987
955
  : {
988
- ...state.product,
989
- variantsCount: Math.max((state.product?.variantsCount ?? 1) - 1, 0),
990
- };
956
+ ...state.product,
957
+ variantsCount: Math.max((state.product?.variantsCount ?? 1) - 1, 0),
958
+ };
991
959
 
992
960
  dispatch({ type: "SET_PRODUCT", payload: updatedProduct });
993
961
 
@@ -996,7 +964,7 @@ const RetailerProductEditionView = ({
996
964
  // aplanada de esta pantalla lo dejaría mutilado en el siguiente F5.
997
965
  try {
998
966
  const guardado = JSON.parse(
999
- sessionStorage.getItem("productSelected") ?? "null",
967
+ sessionStorage.getItem("productSelected") ?? "null"
1000
968
  );
1001
969
 
1002
970
  if (guardado) {
@@ -1006,7 +974,7 @@ const RetailerProductEditionView = ({
1006
974
  ...guardado,
1007
975
  variantsCount: updatedProduct.variantsCount,
1008
976
  parentArticleId: updatedProduct.parentArticleId ?? null,
1009
- }),
977
+ })
1010
978
  );
1011
979
  }
1012
980
  } catch (error) {
@@ -1047,7 +1015,7 @@ const RetailerProductEditionView = ({
1047
1015
  const res = await axios.post(
1048
1016
  `${process.env.REACT_APP_VARIANTS_ENDPOINT}/group`,
1049
1017
  { articleId: state.product?.id_article },
1050
- { headers: { Authorization: token } },
1018
+ { headers: { Authorization: token } }
1051
1019
  );
1052
1020
  const data = readPayload(res);
1053
1021
  setVariantGroup(data);
@@ -1070,7 +1038,7 @@ const RetailerProductEditionView = ({
1070
1038
  const res = await axios.post(
1071
1039
  `${process.env.REACT_APP_VARIANTS_ENDPOINT}/leave`,
1072
1040
  { articleId: state.product?.id_article },
1073
- { headers: { Authorization: token } },
1041
+ { headers: { Authorization: token } }
1074
1042
  );
1075
1043
  setVariantGroup(readPayload(res));
1076
1044
  setShowGroup(false);
@@ -1105,7 +1073,7 @@ const RetailerProductEditionView = ({
1105
1073
  {
1106
1074
  params: { filterByIdArticle: articleId, includeFilters: false },
1107
1075
  headers: { Authorization: token },
1108
- },
1076
+ }
1109
1077
  );
1110
1078
 
1111
1079
  // read-order-services responde { total, data: [...] }. La vista trae una
@@ -1113,12 +1081,12 @@ const RetailerProductEditionView = ({
1113
1081
  // ordenadas por order_id DESC y la primera es la orden más reciente.
1114
1082
  const { data } = readPayload(res) ?? {};
1115
1083
  const encontrado = (data ?? []).find(
1116
- (item) => Number(item?.article?.id_article) === Number(articleId),
1084
+ (item) => Number(item?.article?.id_article) === Number(articleId)
1117
1085
  );
1118
1086
 
1119
1087
  if (!encontrado) {
1120
1088
  setAvisoFamilia(
1121
- "Ese producto no está en ninguna orden de trabajo, así que no se puede abrir desde aquí.",
1089
+ "Ese producto no está en ninguna orden de trabajo, así que no se puede abrir desde aquí."
1122
1090
  );
1123
1091
  return;
1124
1092
  }
@@ -1131,7 +1099,7 @@ const RetailerProductEditionView = ({
1131
1099
  ArticleId: encontrado.article.id_article,
1132
1100
  categoryId: encontrado.article.id_article,
1133
1101
  product: [encontrado],
1134
- }),
1102
+ })
1135
1103
  );
1136
1104
 
1137
1105
  window.location.assign("/EditProducts");
@@ -1156,8 +1124,7 @@ const RetailerProductEditionView = ({
1156
1124
  ? {
1157
1125
  ...state.product,
1158
1126
  variantsCount:
1159
- (state.product?.variantsCount ?? 0) +
1160
- (result.linked ?? []).length,
1127
+ (state.product?.variantsCount ?? 0) + (result.linked ?? []).length,
1161
1128
  }
1162
1129
  : {
1163
1130
  ...state.product,
@@ -1181,7 +1148,7 @@ const RetailerProductEditionView = ({
1181
1148
  */
1182
1149
  try {
1183
1150
  const guardado = JSON.parse(
1184
- sessionStorage.getItem("productSelected") ?? "null",
1151
+ sessionStorage.getItem("productSelected") ?? "null"
1185
1152
  );
1186
1153
 
1187
1154
  if (guardado) {
@@ -1191,7 +1158,7 @@ const RetailerProductEditionView = ({
1191
1158
  ...guardado,
1192
1159
  variantsCount: updatedProduct.variantsCount,
1193
1160
  parentArticleId: updatedProduct.parentArticleId ?? null,
1194
- }),
1161
+ })
1195
1162
  );
1196
1163
  }
1197
1164
  } catch (error) {
@@ -1233,10 +1200,7 @@ const RetailerProductEditionView = ({
1233
1200
  `rel_article_description` y la ficha técnica en `article_data`: el endpoint
1234
1201
  recibe una lista o la otra.
1235
1202
  */
1236
- const handleOnRestoreInheritance = async (
1237
- attributeId,
1238
- kind = "attribute",
1239
- ) => {
1203
+ const handleOnRestoreInheritance = async (attributeId, kind = "attribute") => {
1240
1204
  if (!attributeId || !state.product?.id_article) return;
1241
1205
 
1242
1206
  const esDescripcion = kind === "description";
@@ -1250,7 +1214,7 @@ const RetailerProductEditionView = ({
1250
1214
  ? { descriptions: [Number(attributeId)] }
1251
1215
  : { attributes: [Number(attributeId)] }),
1252
1216
  },
1253
- { headers: { Authorization: token } },
1217
+ { headers: { Authorization: token } }
1254
1218
  );
1255
1219
 
1256
1220
  const data = readPayload(res);
@@ -1377,7 +1341,20 @@ const RetailerProductEditionView = ({
1377
1341
  return;
1378
1342
  }
1379
1343
 
1380
- const hijos = (familia.variants ?? []).filter((v) => !v.isCurrent);
1344
+ /*
1345
+ Un HIJO no propaga a nadie, y hay que decirlo explícitamente.
1346
+
1347
+ `familia.variants` mira la familia desde el PADRE —eso es lo correcto
1348
+ para navegar entre hermanos— así que desde la ficha de un hijo esa
1349
+ lista son sus HERMANOS. Leerla como «los destinos de este producto»
1350
+ hacía que el diálogo le ofreciera heredar a sus hermanos. El servidor
1351
+ resolvía los destinos por su cuenta y no escribía nada, de modo que el
1352
+ usuario elegía, guardaba, y no pasaba nada: un no-operativo silencioso.
1353
+ */
1354
+ const esHijo = !!familia?.parent;
1355
+ const hijos = esHijo
1356
+ ? []
1357
+ : (familia.variants ?? []).filter((v) => !v.isCurrent);
1381
1358
 
1382
1359
  /*
1383
1360
  También cuentan las compañeras de grupo: un producto puede no tener
@@ -1385,10 +1362,12 @@ const RetailerProductEditionView = ({
1385
1362
  puede copiar igual. Si no se miraran, guardar no ofrecería nada.
1386
1363
  */
1387
1364
  const companeras = (variantGroup?.members ?? []).filter(
1388
- (m) => !m.isCurrent,
1365
+ (m) => !m.isCurrent
1389
1366
  );
1390
1367
 
1391
- if (hijos.length > 0 || companeras.length > 0) {
1368
+ // Un hijo tampoco puede estar agrupado desde 2026-09-25, así que para
1369
+ // él no queda ningún destino posible.
1370
+ if (!esHijo && (hijos.length > 0 || companeras.length > 0)) {
1392
1371
  setPendingPropagation({ cambios });
1393
1372
  return;
1394
1373
  }
@@ -1397,16 +1376,6 @@ const RetailerProductEditionView = ({
1397
1376
  break;
1398
1377
  }
1399
1378
  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
- }
1410
1379
  await updateImages(token);
1411
1380
  break;
1412
1381
  default:
@@ -1430,98 +1399,7 @@ const RetailerProductEditionView = ({
1430
1399
  });
1431
1400
  };
1432
1401
 
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
-
1519
1402
  const handleOnAskToDeleteImages = () => {
1520
- if (currentTray === TRAYS.ASSETS) {
1521
- handleOnAskToDeleteAssets();
1522
- return;
1523
- }
1524
-
1525
1403
  if (state.selected_images.length === 0) {
1526
1404
  dispatch({
1527
1405
  type: "SET_MODAL",
@@ -2235,25 +2113,7 @@ const RetailerProductEditionView = ({
2235
2113
  </Container>
2236
2114
  );
2237
2115
 
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
- )
2116
+ if (state.loading || !state.services || !state.product)
2257
2117
  return (
2258
2118
  <>
2259
2119
  <ProductEditionSkeleton />
@@ -2271,9 +2131,13 @@ const RetailerProductEditionView = ({
2271
2131
  {pendingPropagation && (
2272
2132
  <PropagateChangesModal
2273
2133
  changedAttributes={pendingPropagation.cambios}
2274
- variants={(family?.variants ?? []).filter((v) => !v.isCurrent)}
2134
+ variants={
2135
+ esHijoDeOtro
2136
+ ? []
2137
+ : (family?.variants ?? []).filter((v) => !v.isCurrent)
2138
+ }
2275
2139
  groupMembers={(variantGroup?.members ?? []).filter(
2276
- (m) => !m.isCurrent,
2140
+ (m) => !m.isCurrent
2277
2141
  )}
2278
2142
  groupCode={variantGroup?.group ?? null}
2279
2143
  saving={state.saving}
@@ -2490,18 +2354,8 @@ const RetailerProductEditionView = ({
2490
2354
  <div className="data-container">
2491
2355
  <div className="image-data-panel">
2492
2356
  <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
- */
2503
2357
  activeImage={
2504
- state.images_values?.values?.[state.current_image] ?? {}
2358
+ state.images_values.values[state.current_image] ?? {}
2505
2359
  }
2506
2360
  imagesArray={state.images_values || []}
2507
2361
  setActiveImage={handleOnChangeCurrentImage}
@@ -2688,6 +2542,7 @@ const RetailerProductEditionView = ({
2688
2542
  herencia y tiene jerarquía; esto es solo un código compartido
2689
2543
  que dice "estos son el mismo producto en otra presentación".
2690
2544
  Juntarlos haría creer que agrupar hereda algo, que no pasa. */}
2545
+ {puedeAgruparse && (
2691
2546
  <div className="variants-actions">
2692
2547
  <button
2693
2548
  type="button"
@@ -2709,8 +2564,9 @@ const RetailerProductEditionView = ({
2709
2564
  </button>
2710
2565
  )}
2711
2566
  </div>
2567
+ )}
2712
2568
 
2713
- {showGroup && variantGroup?.group && (
2569
+ {puedeAgruparse && showGroup && variantGroup?.group && (
2714
2570
  <div className="variants-family">
2715
2571
  {(variantGroup.members ?? [])
2716
2572
  .filter((miembro) => !miembro.isCurrent)
@@ -2807,13 +2663,7 @@ const RetailerProductEditionView = ({
2807
2663
  setShowVersionSelector={setShowVersionSelector}
2808
2664
  // setSelectedImages={setSelectedImages} prop pasada asi: FullTabsMenu -> TabsMenu Pero en TabsMenu no se espera ninguna prop setSelectedImages
2809
2665
  // actions
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
- }
2666
+ downloadImages={handleOnDownloadImages}
2817
2667
  askToDeleteImages={handleOnAskToDeleteImages}
2818
2668
  showSaveButton={isAuditorAssigned() || isUserAssignedToService()}
2819
2669
  onClickSave={handleOnClickSave}
@@ -2891,36 +2741,7 @@ const RetailerProductEditionView = ({
2891
2741
  {state.active_tab === "Imágenes" &&
2892
2742
  (state.product?.images_status !== "NS" ? (
2893
2743
  <>
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 && (
2744
+ {!imageLayout && (
2924
2745
  <GalleryHeader
2925
2746
  checkAll={state.all_image_checked} // Determina si el checkbox "Seleccionar todo" está marcado
2926
2747
  setCheckAll={handleOnSetCheckAll} // Toggler de true o false para el checkbox "Seleccionar todo"
@@ -2928,76 +2749,74 @@ const RetailerProductEditionView = ({
2928
2749
  // shotThd={shotThd} // No se usa?
2929
2750
  />
2930
2751
  )}
2931
- {currentTray === TRAYS.IMAGES && (
2932
- <section
2933
- className="container"
2934
- style={{ position: "relative" }}
2752
+ <section
2753
+ className="container"
2754
+ style={{ position: "relative" }}
2755
+ >
2756
+ <div
2757
+ {...getRootProps({
2758
+ className: `dropzone ${
2759
+ isDragActive ? "drag-active" : ""
2760
+ }`,
2761
+ })}
2935
2762
  >
2763
+ <input {...getInputProps()} />
2764
+ {isDragActive && (
2765
+ <div className="drag-overlay">
2766
+ <p>Suelta las imágenes aquí</p>
2767
+ </div>
2768
+ )}
2769
+ <aside>{thumbs()}</aside>
2770
+ </div>
2771
+ {state.images_values?.values.length === 0 && (
2936
2772
  <div
2937
- {...getRootProps({
2938
- className: `dropzone ${
2939
- isDragActive ? "drag-active" : ""
2940
- }`,
2941
- })}
2773
+ style={{
2774
+ position: "absolute",
2775
+ top: "50%",
2776
+ left: "50%",
2777
+ transform: "translate(-50%, -50%)",
2778
+ textAlign: "center",
2779
+ padding: "40px",
2780
+ width: "80%",
2781
+ maxWidth: "500px",
2782
+ fontFamily: "Arial, sans-serif",
2783
+ }}
2942
2784
  >
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
2785
+ <p
2953
2786
  style={{
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",
2787
+ fontSize: "18px",
2788
+ color: "#666",
2789
+ marginBottom: "16px",
2963
2790
  }}
2964
2791
  >
2792
+ Este producto no tiene imágenes
2793
+ </p>
2794
+ {isAuditorAssigned() ||
2795
+ isUserAssignedToService() ? (
2965
2796
  <p
2966
2797
  style={{
2967
- fontSize: "18px",
2968
- color: "#666",
2969
- marginBottom: "16px",
2798
+ fontSize: "14px",
2799
+ color: "#999",
2800
+ marginBottom: "20px",
2970
2801
  }}
2971
2802
  >
2972
- Este producto no tiene imágenes
2973
- </p>
2974
- {isAuditorAssigned() ||
2975
- isUserAssignedToService() ? (
2976
- <p
2803
+ Arrastra las imágenes aquí o{" "}
2804
+ <span
2805
+ onClick={open}
2977
2806
  style={{
2978
- fontSize: "14px",
2979
- color: "#999",
2980
- marginBottom: "20px",
2807
+ color: "#007bff",
2808
+ cursor: "pointer",
2809
+ textDecoration: "underline",
2981
2810
  }}
2982
2811
  >
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
- )}
2812
+ haz clic para abrir el explorador de
2813
+ archivos
2814
+ </span>
2815
+ </p>
2816
+ ) : null}
2817
+ </div>
2818
+ )}
2819
+ </section>
3001
2820
  </>
3002
2821
  ) : (
3003
2822
  <ScreenHeader