contentoh-components-library 21.6.55 → 21.6.56

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 (82) 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/pages/RetailerProductEdition/context/provider-product-edition.context.js +330 -15
  32. package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +12 -2
  33. package/dist/components/pages/RetailerProductEdition/index.js +606 -425
  34. package/dist/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +1 -0
  35. package/dist/global-files/data.js +16 -3
  36. package/dist/global-files/digital-assets/assetTypes.js +480 -0
  37. package/dist/global-files/digital-assets/assets.actions.js +64 -0
  38. package/dist/global-files/digital-assets/assets.adapter.js +304 -0
  39. package/dist/global-files/digital-assets/assets.mock.js +136 -0
  40. package/dist/global-files/digital-assets/assets.reducer.js +220 -0
  41. package/dist/global-files/digital-assets/uploadAssets.js +179 -0
  42. package/dist/global-files/digital-assets/useDigitalAssets.js +522 -0
  43. package/dist/index.js +142 -90
  44. package/docs/activos-digitales.md +273 -0
  45. package/package.json +1 -1
  46. package/src/components/atoms/AssetPreview/AssetPreview.stories.js +78 -0
  47. package/src/components/atoms/AssetPreview/index.js +119 -0
  48. package/src/components/atoms/AssetPreview/styles.js +190 -0
  49. package/src/components/atoms/IconFile/index.js +28 -1
  50. package/src/components/atoms/IconFile/styles.js +8 -0
  51. package/src/components/atoms/ProductImage/ProductImage.stories.js +38 -0
  52. package/src/components/atoms/ProductImage/index.js +95 -20
  53. package/src/components/atoms/ProductImage/styles.js +92 -7
  54. package/src/components/molecules/EditionActiveImage/index.js +2 -2
  55. package/src/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +102 -0
  56. package/src/components/molecules/GalleryAssetElement/index.js +162 -0
  57. package/src/components/molecules/GalleryAssetElement/styles.js +159 -0
  58. package/src/components/molecules/GalleryElement/index.js +9 -0
  59. package/src/components/molecules/GalleryHeader/index.js +65 -13
  60. package/src/components/molecules/GalleryHeader/styles.js +2 -1
  61. package/src/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +32 -0
  62. package/src/components/molecules/GalleryTrayTabs/index.js +41 -0
  63. package/src/components/molecules/GalleryTrayTabs/styles.js +52 -0
  64. package/src/components/molecules/ImageSelector/index.js +1 -0
  65. package/src/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +101 -0
  66. package/src/components/organisms/DigitalAssetsTray/index.js +189 -0
  67. package/src/components/organisms/DigitalAssetsTray/styles.js +183 -0
  68. package/src/components/organisms/ImagePreviewer/index.js +1 -0
  69. package/src/components/organisms/ProductImageModal/index.js +1 -0
  70. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.context.jsx +201 -0
  71. package/src/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +12 -0
  72. package/src/components/pages/RetailerProductEdition/index.js +356 -139
  73. package/src/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +1 -0
  74. package/src/global-files/data.js +17 -2
  75. package/src/global-files/digital-assets/assetTypes.js +419 -0
  76. package/src/global-files/digital-assets/assets.actions.js +50 -0
  77. package/src/global-files/digital-assets/assets.adapter.js +229 -0
  78. package/src/global-files/digital-assets/assets.mock.js +122 -0
  79. package/src/global-files/digital-assets/assets.reducer.js +197 -0
  80. package/src/global-files/digital-assets/uploadAssets.js +109 -0
  81. package/src/global-files/digital-assets/useDigitalAssets.js +329 -0
  82. package/src/index.js +4 -0
@@ -0,0 +1,189 @@
1
+ import { useDropzone } from "react-dropzone";
2
+
3
+ import { Container } from "./styles";
4
+ import { GalleryAssetElement } from "../../molecules/GalleryAssetElement";
5
+ import { GalleryHeader } from "../../molecules/GalleryHeader";
6
+ import { ButtonV2 } from "../../atoms/ButtonV2";
7
+ import {
8
+ MAX_ASSETS_PER_PRODUCT,
9
+ acceptedFormatsLabel,
10
+ buildDropzoneAccept,
11
+ } from "../../../global-files/digital-assets/assetTypes";
12
+
13
+ const CHECK_ALL_ID = "check-all-assets";
14
+ const ITEM_SELECTOR = "[id^='gallery-asset-']";
15
+
16
+ /**
17
+ * Bandeja de activos digitales: soltar archivos, verlos, reetiquetarlos y
18
+ * quitarlos.
19
+ *
20
+ * Tiene su propio dropzone en vez de compartir el de las imágenes porque los
21
+ * formatos aceptados son distintos: mezclarlos dejaría soltar un PDF sobre la
22
+ * bandeja de fotos y un JPG sobre la de activos.
23
+ *
24
+ * No guarda ni sube nada. Recibe el estado ya armado por `useDigitalAssets`,
25
+ * así que cuando se defina el contrato del backend solo hay que conectarle las
26
+ * llamadas; la interfaz no cambia.
27
+ */
28
+ export const DigitalAssetsTray = ({
29
+ assets = [],
30
+ notices = [],
31
+ unsaved = { nuevos: 0, modificados: 0, borrados: 0, total: 0 },
32
+ remaining = MAX_ASSETS_PER_PRODUCT,
33
+ addFiles,
34
+ removeAsset,
35
+ changeAssetType,
36
+ clearNotices,
37
+ selectedAssets = [],
38
+ setSelectedAssets,
39
+ setCheckAll,
40
+ gridLayout = false,
41
+ readOnly = false,
42
+ emptyMessage = "Este producto no tiene activos digitales",
43
+ }) => {
44
+ const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
45
+ accept: buildDropzoneAccept(),
46
+ noKeyboard: true,
47
+ multiple: true,
48
+ noClick: true,
49
+ disabled: readOnly,
50
+ onDrop: (acceptedFiles) => addFiles?.(acceptedFiles),
51
+ });
52
+
53
+ const noticesBy = (level) => notices.filter((item) => item.level === level);
54
+
55
+ /*
56
+ Se desglosa en vez de mostrar solo el número: "3 cambios sin guardar" no le
57
+ dice al usuario qué perdería si se va, y no es lo mismo dejar sin guardar un
58
+ borrado que un archivo nuevo.
59
+ */
60
+ const detalleSinGuardar = [
61
+ unsaved.nuevos && `${unsaved.nuevos} nuevo${unsaved.nuevos > 1 ? "s" : ""}`,
62
+ unsaved.modificados && `${unsaved.modificados} modificado${unsaved.modificados > 1 ? "s" : ""}`,
63
+ unsaved.borrados && `${unsaved.borrados} por borrar`,
64
+ ].filter(Boolean).join(" · ");
65
+
66
+ return (
67
+ <Container>
68
+ <div className="tray-toolbar">
69
+ {/* La lista completa son 27 formatos: no cabe en una línea, así que va
70
+ en el title y en el texto solo las familias. */}
71
+ <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}.
74
+ </p>
75
+ {!readOnly && (
76
+ <ButtonV2
77
+ label={"Agregar activos"}
78
+ type={"pink"}
79
+ size={12}
80
+ onClick={open}
81
+ />
82
+ )}
83
+ </div>
84
+
85
+ {/*
86
+ Rechazos y advertencias se separan en dos paneles.
87
+
88
+ Mezclarlos en rojo haría creer que un archivo que sí entró tampoco
89
+ sirvió: un .mov se guarda y se descarga bien, solo que el navegador no
90
+ lo reproduce. Son mensajes distintos y merecen colores distintos.
91
+ */}
92
+ {unsaved.total > 0 && (
93
+ <div className="tray-unsaved" role="status">
94
+ <span className="tray-unsaved-dot" />
95
+ <p>
96
+ <strong>
97
+ {unsaved.total} cambio{unsaved.total > 1 ? "s" : ""} sin guardar
98
+ </strong>
99
+ {detalleSinGuardar && <span className="tray-unsaved-detail"> — {detalleSinGuardar}</span>}
100
+ </p>
101
+ </div>
102
+ )}
103
+
104
+ {noticesBy("error").length > 0 && (
105
+ <div className="tray-notice tray-notice-error">
106
+ <div className="tray-notice-head">
107
+ <p>No se pudieron agregar {noticesBy("error").length} archivo(s):</p>
108
+ <button type="button" onClick={clearNotices} title="Cerrar">
109
+ ×
110
+ </button>
111
+ </div>
112
+ <ul>
113
+ {noticesBy("error").map((item, index) => (
114
+ <li key={`err-${item.name}-${index}`}>{item.message}</li>
115
+ ))}
116
+ </ul>
117
+ </div>
118
+ )}
119
+
120
+ {noticesBy("warning").length > 0 && (
121
+ <div className="tray-notice tray-notice-warning">
122
+ <div className="tray-notice-head">
123
+ <p>Se agregaron, pero ten en cuenta:</p>
124
+ <button type="button" onClick={clearNotices} title="Cerrar">
125
+ ×
126
+ </button>
127
+ </div>
128
+ <ul>
129
+ {noticesBy("warning").map((item, index) => (
130
+ <li key={`warn-${item.name}-${index}`}>{item.message}</li>
131
+ ))}
132
+ </ul>
133
+ </div>
134
+ )}
135
+
136
+ {!gridLayout && assets.length > 0 && (
137
+ <GalleryHeader
138
+ variant="asset"
139
+ checkAllId={CHECK_ALL_ID}
140
+ itemSelector={ITEM_SELECTOR}
141
+ setCheckAll={setCheckAll}
142
+ setSelectedImages={setSelectedAssets}
143
+ />
144
+ )}
145
+
146
+ <div
147
+ {...getRootProps({
148
+ className: `dropzone ${isDragActive ? "drag-active" : ""}`.trim(),
149
+ })}
150
+ >
151
+ <input {...getInputProps()} />
152
+ {isDragActive && (
153
+ <div className="drag-overlay">
154
+ <p>Suelta los activos aquí</p>
155
+ </div>
156
+ )}
157
+ <aside className={gridLayout ? "tray-grid" : "tray-rows"}>
158
+ {assets.map((asset, index) => (
159
+ <GalleryAssetElement
160
+ key={asset.id ?? asset.tempId}
161
+ asset={asset}
162
+ index={index}
163
+ gridLayout={gridLayout}
164
+ checkAllId={CHECK_ALL_ID}
165
+ itemSelector={ITEM_SELECTOR}
166
+ selectedAssets={selectedAssets}
167
+ setSelectedAssets={setSelectedAssets}
168
+ setCheckAll={setCheckAll}
169
+ changeAsset={({ value }) => changeAssetType?.(index, value)}
170
+ onRemove={removeAsset}
171
+ readOnly={readOnly}
172
+ />
173
+ ))}
174
+ </aside>
175
+
176
+ {assets.length === 0 && (
177
+ <div className="tray-empty">
178
+ <p>{emptyMessage}</p>
179
+ {!readOnly && (
180
+ <p className="tray-empty-hint">
181
+ Arrástralos aquí o usa «Agregar activos».
182
+ </p>
183
+ )}
184
+ </div>
185
+ )}
186
+ </div>
187
+ </Container>
188
+ );
189
+ };
@@ -0,0 +1,183 @@
1
+ import styled from "styled-components";
2
+ import { FontFamily, GlobalColors } from "../../../global-files/variables";
3
+
4
+ /* El dropzone replica el de la bandeja de imágenes para que arrastrar se sienta
5
+ igual en las dos. */
6
+ export const Container = styled.div`
7
+ width: 100%;
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 8px;
11
+
12
+ .tray-toolbar {
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: space-between;
16
+ gap: 12px;
17
+ }
18
+
19
+ .tray-hint {
20
+ margin: 0;
21
+ font-family: ${FontFamily.Lato};
22
+ font-size: 12px;
23
+ line-height: 18px;
24
+ color: ${GlobalColors.s4};
25
+ }
26
+
27
+ /*
28
+ Aviso de cambios sin guardar. Va arriba del listado y no junto al botón de
29
+ guardar, que vive en otra barra: el usuario mira la bandeja, no el menú.
30
+ */
31
+ .tray-unsaved {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 8px;
35
+ padding: 6px 12px;
36
+ border-radius: 6px;
37
+ border: 1px solid ${GlobalColors.in_progress};
38
+ background: rgba(237, 154, 77, 0.08);
39
+
40
+ p {
41
+ margin: 0;
42
+ font-family: ${FontFamily.Lato};
43
+ font-size: 12px;
44
+ line-height: 18px;
45
+ color: ${GlobalColors.s5};
46
+ }
47
+
48
+ strong {
49
+ font-family: ${FontFamily.Raleway_600};
50
+ color: ${GlobalColors.in_progress};
51
+ }
52
+ }
53
+
54
+ .tray-unsaved-dot {
55
+ width: 8px;
56
+ height: 8px;
57
+ border-radius: 50%;
58
+ background: ${GlobalColors.in_progress};
59
+ flex-shrink: 0;
60
+ }
61
+
62
+ .tray-unsaved-detail {
63
+ color: ${GlobalColors.s4};
64
+ }
65
+
66
+ .tray-notice {
67
+ border-radius: 6px;
68
+ padding: 8px 12px;
69
+ border: 1px solid;
70
+
71
+ .tray-notice-head {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: space-between;
75
+
76
+ p {
77
+ margin: 0;
78
+ font-family: ${FontFamily.Raleway_600};
79
+ font-size: 12px;
80
+ }
81
+
82
+ button {
83
+ border: none;
84
+ background: none;
85
+ cursor: pointer;
86
+ font-size: 16px;
87
+ line-height: 1;
88
+ color: inherit;
89
+ }
90
+ }
91
+
92
+ ul {
93
+ margin: 4px 0 0;
94
+ padding-left: 18px;
95
+ }
96
+
97
+ li {
98
+ font-family: ${FontFamily.Lato};
99
+ font-size: 12px;
100
+ line-height: 18px;
101
+ color: ${GlobalColors.s5};
102
+ }
103
+ }
104
+
105
+ /* Rojo = no entró. Ámbar = entró, pero hay algo que saber. */
106
+ .tray-notice-error {
107
+ border-color: ${GlobalColors.secondary_magenta};
108
+ background: rgba(${GlobalColors.rgb_color_pink}, 0.05);
109
+ color: ${GlobalColors.secondary_magenta};
110
+ }
111
+
112
+ .tray-notice-warning {
113
+ border-color: ${GlobalColors.in_progress};
114
+ background: rgba(237, 154, 77, 0.08);
115
+ color: ${GlobalColors.in_progress};
116
+ }
117
+
118
+ .dropzone {
119
+ position: relative;
120
+ width: 100%;
121
+ min-height: 160px;
122
+ transition: all 0.5s ease;
123
+
124
+ &.drag-active {
125
+ background-color: rgba(${GlobalColors.rgb_color_pink}, 0.05);
126
+ border: 2px dashed ${GlobalColors.color_pink};
127
+ border-radius: 8px;
128
+
129
+ aside {
130
+ opacity: 0.3;
131
+ pointer-events: none;
132
+ }
133
+ }
134
+
135
+ .drag-overlay {
136
+ position: absolute;
137
+ inset: 0;
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ z-index: 1;
142
+
143
+ p {
144
+ font-family: ${FontFamily.AvenirNext};
145
+ font-size: 16px;
146
+ color: ${GlobalColors.color_pink};
147
+ }
148
+ }
149
+ }
150
+
151
+ .tray-rows {
152
+ display: flex;
153
+ flex-direction: column;
154
+ }
155
+
156
+ .tray-grid {
157
+ display: flex;
158
+ flex-wrap: wrap;
159
+ gap: 12px;
160
+ }
161
+
162
+ .tray-empty {
163
+ position: absolute;
164
+ top: 50%;
165
+ left: 50%;
166
+ transform: translate(-50%, -50%);
167
+ text-align: center;
168
+ width: 80%;
169
+
170
+ p {
171
+ margin: 0;
172
+ font-family: ${FontFamily.Lato};
173
+ font-size: 16px;
174
+ color: ${GlobalColors.gray};
175
+ }
176
+
177
+ .tray-empty-hint {
178
+ margin-top: 6px;
179
+ font-size: 13px;
180
+ color: ${GlobalColors.s4};
181
+ }
182
+ }
183
+ `;
@@ -13,6 +13,7 @@ export const ImagePreviewer = ({
13
13
  <EditionActiveImage
14
14
  img={activeImage?.src}
15
15
  altText={activeImage?.name}
16
+ ext={activeImage?.ext}
16
17
  setShowModal={setShowModal}
17
18
  />
18
19
  <ImageSelector images={imagesArray} setActiveImage={setActiveImage} />
@@ -48,6 +48,7 @@ export const ProductImageModal = ({
48
48
  images?.values[activeImage]?.srcDB
49
49
  }
50
50
  altText={images?.values[activeImage]?.name}
51
+ ext={images?.values[activeImage]?.ext}
51
52
  imageType={"expanded-modal-image"}
52
53
  />
53
54
  </div>
@@ -13,6 +13,13 @@ import successIcon from "../../../../assets/images/genericModal/genericModalChec
13
13
  import errorIcon from "../../../../assets/images/genericModal/errorModal.svg";
14
14
  import warningIcon from "../../../../assets/images/genericModal/genericModalWarning.svg";
15
15
  import { getConceptByTab } from "../utils";
16
+ import {
17
+ assetKey,
18
+ buildSavePayload,
19
+ isPersisted,
20
+ toPayload,
21
+ } from "../../../../global-files/digital-assets/assets.adapter";
22
+ import { selectAssetsToSave } from "../../../../global-files/digital-assets/assets.reducer";
16
23
 
17
24
  const S3_BUCKET = process.env.REACT_APP_IMAGES_BUCKET;
18
25
  const REGION = "us-east-1";
@@ -555,6 +562,198 @@ export const ProviderProductEditionProvider = ({ children }) => {
555
562
  dispatch({ type: "SET_SAVING", payload: false });
556
563
  };
557
564
 
565
+ /*
566
+ Registra en la base los activos que ACABAN de subirse, sin esperar a que el
567
+ usuario guarde.
568
+
569
+ Es una corrección de expectativa, no una comodidad: al ver la barra de
570
+ progreso llegar al final, cualquiera da por hecho que el activo ya quedó
571
+ ligado al producto. Si entonces cierra la ficha, el archivo se queda en S3
572
+ sin fila en la base —invisible y sin forma de recuperarlo desde la
573
+ pantalla—.
574
+
575
+ Manda SOLO las altas recién subidas, con `updateAssets` y `deleteAssets`
576
+ vacíos. Un reetiquetado o un borrado que el usuario todavía no confirmó no
577
+ se aplica por haber soltado otro archivo: el botón de guardar sigue siendo
578
+ quien decide sobre esos.
579
+ */
580
+ const registerUploadedAssets = async (token, uploaded = []) => {
581
+ const listos = uploaded.filter((asset) => asset.src && !isPersisted(asset));
582
+ if (listos.length === 0) return;
583
+
584
+ const data = {
585
+ articleId: state.product?.id_article,
586
+ newAssets: listos.map(toPayload),
587
+ updateAssets: [],
588
+ deleteAssets: [],
589
+ };
590
+
591
+ if (state.product?.orderId || state.product?.id_order) {
592
+ data.orderId = state.product?.orderId || state.product?.id_order;
593
+ }
594
+
595
+ try {
596
+ const res = await axios.put(
597
+ process.env.REACT_APP_ARTICLE_DATA_ENDPOINT,
598
+ data,
599
+ { params: { asset: true }, headers: { Authorization: token } },
600
+ );
601
+
602
+ if (res.data.statusCode !== 200) throw new Error("Respuesta inesperada");
603
+
604
+ const body = JSON.parse(res.data.body);
605
+ const guardados = body.assets ?? [];
606
+
607
+ /*
608
+ Se le pone a cada activo el id que le tocó, emparejando por la clave de
609
+ S3, que es única.
610
+
611
+ A propósito NO se reemplaza la lista entera con lo que devolvió el
612
+ backend: eso borraría el rastro de lo que el usuario cambió o borró y
613
+ todavía no ha guardado. Aquí solo se completa lo que faltaba.
614
+ */
615
+ listos.forEach((asset) => {
616
+ const fila = guardados.find((row) => row.src === asset.src);
617
+ if (!fila) return;
618
+ dispatch({
619
+ type: "PATCH_ASSET",
620
+ payload: { key: assetKey(asset), changes: { id: fila.id, url: fila.url } },
621
+ });
622
+ });
623
+ } catch (error) {
624
+ console.log("Error registering assets:", error);
625
+ /*
626
+ El archivo SÍ está en S3; lo que falló es la fila. Se marca en el activo
627
+ y no en un modal porque el usuario puede estar soltando más archivos:
628
+ interrumpirlo con un diálogo por cada fallo sería peor que dejarlo
629
+ señalado en su renglón, donde además se ve cuál falló.
630
+ */
631
+ listos.forEach((asset) => dispatch({
632
+ type: "PATCH_ASSET",
633
+ payload: {
634
+ key: assetKey(asset),
635
+ changes: {
636
+ status: "error",
637
+ error: "Se subió el archivo pero no se pudo guardar. Usa el botón de guardar.",
638
+ },
639
+ },
640
+ }));
641
+ }
642
+ };
643
+
644
+ /*
645
+ Guarda los activos digitales: registra los nuevos, manda los cambios y pide
646
+ las bajas.
647
+
648
+ Va aparte de `updateImages` y no dentro, aunque las dos bandejas vivan en la
649
+ misma pestaña. Un activo no pasa por ninguna de las reglas de una imagen —ni
650
+ tipo de toma, ni obligatoriedad, ni cupo por cadena— y el backend los escribe
651
+ en otra tabla. Mezclarlos obligaría a partir en dos cada validación de
652
+ `updateImages`, que ya es la función más delicada de este archivo.
653
+
654
+ Igual que con las imágenes, aquí NO viaja ningún binario: el archivo ya está
655
+ en S3 y esto solo registra metadatos. Mandar el archivo en el cuerpo devuelve
656
+ 413.
657
+ */
658
+ const saveAssets = async (token) => {
659
+ const payload = buildSavePayload(selectAssetsToSave(state));
660
+ const { newAssets, updateAssets, deleteAssets } = payload;
661
+
662
+ if (!newAssets.length && !updateAssets.length && !deleteAssets.length) {
663
+ return false;
664
+ }
665
+
666
+ /*
667
+ Un activo nuevo sin `key` es un archivo que todavía no llegó a S3.
668
+ Registrarlo crearía una fila apuntando a un objeto que no existe: la
669
+ bandeja lo mostraría y al descargarlo daría 404, sin forma de saber por qué.
670
+ Mejor no guardar nada y decirlo.
671
+ */
672
+ const sinSubir = newAssets.filter((asset) => !asset.key);
673
+ if (sinSubir.length > 0) {
674
+ dispatch({
675
+ type: "SET_MODAL",
676
+ payload: {
677
+ show: true,
678
+ title: "Faltan archivos por subir",
679
+ message: `${sinSubir.length} activo(s) todavía no se han subido. Espera a que termine la carga e intenta de nuevo.`,
680
+ image: warningIcon,
681
+ },
682
+ });
683
+ return false;
684
+ }
685
+
686
+ dispatch({ type: "SET_SAVING", payload: true });
687
+
688
+ // Sin `version`: un activo general es el mismo para todas las versiones del
689
+ // producto, así que ni la fila ni la clave de S3 la llevan.
690
+ const data = {
691
+ articleId: state.product?.id_article,
692
+ newAssets,
693
+ updateAssets,
694
+ deleteAssets,
695
+ };
696
+
697
+ if (state.product?.orderId || state.product?.id_order) {
698
+ data.orderId = state.product?.orderId || state.product?.id_order;
699
+ }
700
+
701
+ try {
702
+ const res = await axios.put(
703
+ process.env.REACT_APP_ARTICLE_DATA_ENDPOINT,
704
+ data,
705
+ {
706
+ params: { asset: true },
707
+ headers: { Authorization: token },
708
+ },
709
+ );
710
+
711
+ if (res.data.statusCode === 200) {
712
+ // Se olvida qué cambió y qué se borró: lo que hay en pantalla ya es lo
713
+ // que hay en la base. Sin esto, el siguiente guardado reenviaría todo.
714
+ dispatch({ type: "CLEAR_ASSETS_TRACKING" });
715
+ dispatch({
716
+ type: "SET_MODAL",
717
+ payload: {
718
+ show: true,
719
+ title: "",
720
+ message: "Activos digitales guardados con éxito",
721
+ image: successIcon,
722
+ },
723
+ });
724
+ /*
725
+ Devuelve `true` para que quien llama recargue.
726
+
727
+ Es imprescindible, no cortesía: los activos recién creados siguen en
728
+ memoria con su id temporal y sin el id de la base. Sin recargar, el
729
+ siguiente guardado los volvería a mandar como nuevos y se duplicarían.
730
+ Es exactamente el problema que ya tuvieron las imágenes.
731
+ */
732
+ return true;
733
+ }
734
+
735
+ return false;
736
+ } catch (error) {
737
+ console.log("Error saving assets:", error);
738
+ dispatch({
739
+ type: "SET_ERRORS",
740
+ payload: [...state.errors, error],
741
+ });
742
+ dispatch({
743
+ type: "SET_MODAL",
744
+ payload: {
745
+ show: true,
746
+ title: "Error",
747
+ message: "Hubo un error al guardar los activos digitales",
748
+ image: errorIcon,
749
+ },
750
+ });
751
+ return false;
752
+ } finally {
753
+ dispatch({ type: "SET_SAVING", payload: false });
754
+ }
755
+ };
756
+
558
757
  const createComment = async (body, token) => {
559
758
  const concept = getConceptByTab(state.active_tab);
560
759
  const data = {
@@ -583,6 +782,8 @@ export const ProviderProductEditionProvider = ({ children }) => {
583
782
  updateImages,
584
783
  saveImageAttrs,
585
784
  deleteImages,
785
+ saveAssets,
786
+ registerUploadedAssets,
586
787
  handleOnDownloadImages,
587
788
  createComment,
588
789
  };
@@ -18,6 +18,11 @@ import {
18
18
  activeStateInitialState,
19
19
  activeStateReducer,
20
20
  } from "./reducers/active-state";
21
+ import { ASSET_ACTIONS } from "../../../../global-files/digital-assets/assets.actions";
22
+ import {
23
+ assetsInitialState,
24
+ assetsReducer,
25
+ } from "../../../../global-files/digital-assets/assets.reducer";
21
26
 
22
27
  // Initial state
23
28
  export const initialState = {
@@ -25,6 +30,7 @@ export const initialState = {
25
30
  ...productInitialState,
26
31
  ...inputsInitialState,
27
32
  ...activeStateInitialState,
33
+ ...assetsInitialState,
28
34
  };
29
35
 
30
36
  // Action types
@@ -33,6 +39,7 @@ export const ACTIONS = {
33
39
  ...PRODUCT_ACTIONS,
34
40
  ...INPUTS_ACTIONS,
35
41
  ...ACTIVE_STATE_ACTIONS,
42
+ ...ASSET_ACTIONS,
36
43
  };
37
44
 
38
45
  // Reducer function
@@ -58,5 +65,10 @@ export const productEditionReducer = (state, action) => {
58
65
  return activeStateResult;
59
66
  }
60
67
 
68
+ const assetsResult = assetsReducer(state, action);
69
+ if (assetsResult !== null) {
70
+ return assetsResult;
71
+ }
72
+
61
73
  return state;
62
74
  };