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.
- package/.claude/launch.json +14 -0
- package/.env.test +1 -0
- package/dist/ai/prompts/attribute.prompt.js +28 -0
- package/dist/ai/prompts/description.prompt.js +33 -0
- package/dist/ai/schemas/attribute.schema.js +35 -0
- package/dist/ai/schemas/description.schema.js +27 -0
- package/dist/components/atoms/AssetPreview/AssetPreview.stories.js +118 -0
- package/dist/components/atoms/AssetPreview/index.js +150 -0
- package/dist/components/atoms/AssetPreview/styles.js +28 -0
- package/dist/components/atoms/IconFile/index.js +85 -2
- package/dist/components/atoms/IconFile/styles.js +1 -1
- package/dist/components/atoms/ProductImage/ProductImage.stories.js +70 -2
- package/dist/components/atoms/ProductImage/index.js +100 -27
- package/dist/components/atoms/ProductImage/styles.js +3 -1
- package/dist/components/molecules/EditionActiveImage/index.js +4 -2
- package/dist/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +147 -0
- package/dist/components/molecules/GalleryAssetElement/index.js +178 -0
- package/dist/components/molecules/GalleryAssetElement/styles.js +26 -0
- package/dist/components/molecules/GalleryElement/index.js +13 -4
- package/dist/components/molecules/GalleryHeader/index.js +96 -24
- package/dist/components/molecules/GalleryHeader/styles.js +4 -1
- package/dist/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +56 -0
- package/dist/components/molecules/GalleryTrayTabs/index.js +60 -0
- package/dist/components/molecules/GalleryTrayTabs/styles.js +20 -0
- package/dist/components/molecules/ImageSelector/index.js +1 -0
- package/dist/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +171 -0
- package/dist/components/organisms/DigitalAssetsTray/index.js +211 -0
- package/dist/components/organisms/DigitalAssetsTray/styles.js +22 -0
- package/dist/components/organisms/ImagePreviewer/index.js +1 -0
- package/dist/components/organisms/ProductImageModal/index.js +3 -2
- package/dist/components/organisms/PropagateChangesModal/index.js +12 -1
- package/dist/components/organisms/PropagateChangesModal/non-propagable.js +45 -0
- package/dist/components/pages/ProviderProductEdition/index.js +11 -1
- package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.context.js +330 -15
- package/dist/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +12 -2
- package/dist/components/pages/RetailerProductEdition/index.js +605 -414
- package/dist/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +1 -0
- package/dist/components/pages/RetailerProductEdition/stories/TextEditor.stories.js +45 -145
- package/dist/global-files/data.js +16 -3
- package/dist/global-files/digital-assets/assetTypes.js +480 -0
- package/dist/global-files/digital-assets/assets.actions.js +64 -0
- package/dist/global-files/digital-assets/assets.adapter.js +304 -0
- package/dist/global-files/digital-assets/assets.mock.js +136 -0
- package/dist/global-files/digital-assets/assets.reducer.js +220 -0
- package/dist/global-files/digital-assets/uploadAssets.js +179 -0
- package/dist/global-files/digital-assets/useDigitalAssets.js +522 -0
- package/dist/index.js +142 -90
- package/docs/activos-digitales.md +273 -0
- package/package.json +1 -1
- package/src/components/atoms/AssetPreview/AssetPreview.stories.js +78 -0
- package/src/components/atoms/AssetPreview/index.js +119 -0
- package/src/components/atoms/AssetPreview/styles.js +190 -0
- package/src/components/atoms/IconFile/index.js +28 -1
- package/src/components/atoms/IconFile/styles.js +8 -0
- package/src/components/atoms/ProductImage/ProductImage.stories.js +38 -0
- package/src/components/atoms/ProductImage/index.js +95 -20
- package/src/components/atoms/ProductImage/styles.js +92 -7
- package/src/components/molecules/EditionActiveImage/index.js +2 -2
- package/src/components/molecules/GalleryAssetElement/GalleryAssetElement.stories.js +102 -0
- package/src/components/molecules/GalleryAssetElement/index.js +162 -0
- package/src/components/molecules/GalleryAssetElement/styles.js +159 -0
- package/src/components/molecules/GalleryElement/index.js +9 -0
- package/src/components/molecules/GalleryHeader/index.js +65 -13
- package/src/components/molecules/GalleryHeader/styles.js +2 -1
- package/src/components/molecules/GalleryTrayTabs/GalleryTrayTabs.stories.js +32 -0
- package/src/components/molecules/GalleryTrayTabs/index.js +41 -0
- package/src/components/molecules/GalleryTrayTabs/styles.js +52 -0
- package/src/components/molecules/ImageSelector/index.js +1 -0
- package/src/components/organisms/DigitalAssetsTray/DigitalAssetsTray.stories.js +101 -0
- package/src/components/organisms/DigitalAssetsTray/index.js +189 -0
- package/src/components/organisms/DigitalAssetsTray/styles.js +183 -0
- package/src/components/organisms/ImagePreviewer/index.js +1 -0
- package/src/components/organisms/ProductImageModal/index.js +1 -0
- package/src/components/organisms/PropagateChangesModal/index.js +11 -1
- package/src/components/organisms/PropagateChangesModal/non-propagable.js +29 -0
- package/src/components/pages/ProviderProductEdition/index.js +10 -1
- package/src/components/pages/RetailerProductEdition/context/provider-product-edition.context.jsx +201 -0
- package/src/components/pages/RetailerProductEdition/context/provider-product-edition.reducer.js +12 -0
- package/src/components/pages/RetailerProductEdition/index.js +366 -140
- package/src/components/pages/RetailerProductEdition/stories/ImageEditor.stories.js +1 -0
- package/src/components/pages/RetailerProductEdition/stories/TextEditor.stories.js +51 -104
- package/src/global-files/data.js +17 -2
- package/src/global-files/digital-assets/assetTypes.js +419 -0
- package/src/global-files/digital-assets/assets.actions.js +50 -0
- package/src/global-files/digital-assets/assets.adapter.js +229 -0
- package/src/global-files/digital-assets/assets.mock.js +122 -0
- package/src/global-files/digital-assets/assets.reducer.js +197 -0
- package/src/global-files/digital-assets/uploadAssets.js +109 -0
- package/src/global-files/digital-assets/useDigitalAssets.js +329 -0
- package/src/index.js +4 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traducción entre el activo digital como lo guarda el backend y como lo usa la
|
|
3
|
+
* interfaz.
|
|
4
|
+
*
|
|
5
|
+
* DOS DECISIONES DEL NEGOCIO QUE SE NOTAN EN ESTE MODELO:
|
|
6
|
+
*
|
|
7
|
+
* - **Los activos no tienen versión.** Un activo general es el mismo sin
|
|
8
|
+
* importar la versión del producto, así que no lleva `id_version` ni en el
|
|
9
|
+
* modelo ni en la clave de S3. Eso evita el problema que sí tienen las
|
|
10
|
+
* imágenes —copiar archivos al crear una versión— y hace que un video subido
|
|
11
|
+
* en la v2 se vea también al mirar la v1.
|
|
12
|
+
* - **Por ahora todos son generales**, o sea `id_retailer = null`. La columna
|
|
13
|
+
* existe y este adaptador ya la respeta para cuando se pida acotar un activo
|
|
14
|
+
* a una cadena; hasta entonces nada la escribe.
|
|
15
|
+
*
|
|
16
|
+
* Es el único archivo que conoce los nombres de campo del backend. Mientras el
|
|
17
|
+
* contrato siga sin cerrarse, todo lo demás —tarjetas, bandeja, validaciones—
|
|
18
|
+
* se construye contra el modelo de esta capa; cuando el contrato quede fijo se
|
|
19
|
+
* ajustan estas funciones y nada más.
|
|
20
|
+
*
|
|
21
|
+
* El contrato propuesto está en `rel_article_asset`: una tabla aparte de
|
|
22
|
+
* `rel_article_image`, porque esa última guarda una sola fila por
|
|
23
|
+
* (artículo, versión, tipo de toma) y pisaría un activo con el siguiente.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { getAssetType, getExtension, suggestAssetType } from "./assetTypes";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Estado de un activo en pantalla. `pending`/`uploading`/`error` solo existen
|
|
30
|
+
* en el navegador: un activo con id del backend siempre está `saved`.
|
|
31
|
+
*/
|
|
32
|
+
export const ASSET_STATUS = {
|
|
33
|
+
PENDING: "pending",
|
|
34
|
+
UPLOADING: "uploading",
|
|
35
|
+
SAVED: "saved",
|
|
36
|
+
ERROR: "error",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const IMAGES_BUCKET = () => process.env.REACT_APP_IMAGES_BUCKET;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* URL directa al objeto en S3.
|
|
43
|
+
*
|
|
44
|
+
* A propósito NO pasa por el redimensionador de CloudFront: ese servicio
|
|
45
|
+
* reescribe la imagen con sharp y para un mp4, un gif animado o un pdf
|
|
46
|
+
* devuelve basura. Es la misma URL que ya usa la descarga de imágenes.
|
|
47
|
+
*/
|
|
48
|
+
export const buildAssetUrl = (key) => {
|
|
49
|
+
if (!key) return "";
|
|
50
|
+
if (/^https?:\/\//i.test(key)) return key;
|
|
51
|
+
return `https://${IMAGES_BUCKET()}.s3.amazonaws.com/${key}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `Number(null)` y `Number("")` son 0, no NaN. Sin descartarlos antes, un
|
|
56
|
+
* `id_retailer: null` —que significa "de todas las cadenas"— se convierte en el
|
|
57
|
+
* retailer 0 y el activo deja de verse en ninguna; una duración nula se vuelve
|
|
58
|
+
* un video de cero segundos.
|
|
59
|
+
*/
|
|
60
|
+
const toNumberOrNull = (value) => {
|
|
61
|
+
if (value === null || value === undefined || value === "") return null;
|
|
62
|
+
const parsed = Number(value);
|
|
63
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fila del backend -> modelo de interfaz.
|
|
68
|
+
*
|
|
69
|
+
* Acepta `url` ya resuelta si el backend la manda; si no, la arma desde la
|
|
70
|
+
* clave de S3. Así el front funciona igual con cualquiera de las dos formas del
|
|
71
|
+
* contrato y no hay que esperar a que se decida para seguir construyendo.
|
|
72
|
+
*/
|
|
73
|
+
export const fromBackend = (row = {}) => {
|
|
74
|
+
const ext = row.ext || getExtension(row.name || row.src);
|
|
75
|
+
const assetType =
|
|
76
|
+
row.asset_type || suggestAssetType({ mime: row.mime, name: row.name });
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
id: row.id ?? null,
|
|
80
|
+
tempId: null,
|
|
81
|
+
assetType,
|
|
82
|
+
name: row.name || "",
|
|
83
|
+
ext,
|
|
84
|
+
mime: row.mime || "",
|
|
85
|
+
sizeBytes: toNumberOrNull(row.size_bytes),
|
|
86
|
+
duration: toNumberOrNull(row.duration),
|
|
87
|
+
width: toNumberOrNull(row.width),
|
|
88
|
+
height: toNumberOrNull(row.height),
|
|
89
|
+
// Clave en S3. Se conserva porque es lo que el backend necesita de vuelta;
|
|
90
|
+
// la URL es derivada y no debe viajar en el payload.
|
|
91
|
+
src: row.src || "",
|
|
92
|
+
url: row.url || buildAssetUrl(row.src),
|
|
93
|
+
poster: row.poster ? buildAssetUrl(row.poster) : "",
|
|
94
|
+
// `id_retailer` nulo = activo general, que es lo único que se escribe hoy.
|
|
95
|
+
retailerId: toNumberOrNull(row.id_retailer),
|
|
96
|
+
createdAt: row.created_at || null,
|
|
97
|
+
file: null,
|
|
98
|
+
status: ASSET_STATUS.SAVED,
|
|
99
|
+
progress: 100,
|
|
100
|
+
error: null,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const fromBackendList = (rows = []) =>
|
|
105
|
+
Array.isArray(rows) ? rows.map(fromBackend) : [];
|
|
106
|
+
|
|
107
|
+
let tempCounter = 0;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Archivo recién elegido -> modelo de interfaz.
|
|
111
|
+
*
|
|
112
|
+
* El `File` nativo se guarda tal cual, sin leerlo a base64. Convertirlo cuesta
|
|
113
|
+
* un tercio más de memoria y es lo que hoy vuelve imposible subir un video
|
|
114
|
+
* largo o un manual grande; con el `File` intacto da igual si termina subiendo
|
|
115
|
+
* por URL prefirmada o por el cliente de S3.
|
|
116
|
+
*
|
|
117
|
+
* El `tempId` permite seguirle la pista a un activo que todavía no tiene id del
|
|
118
|
+
* backend: sin él no hay forma de actualizar su progreso ni de quitarlo de la
|
|
119
|
+
* lista si falla.
|
|
120
|
+
*/
|
|
121
|
+
export const fromFile = (file, { assetType, objectUrl } = {}) => {
|
|
122
|
+
tempCounter += 1;
|
|
123
|
+
const ext = getExtension(file?.name);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
id: null,
|
|
127
|
+
tempId: `tmp-${Date.now()}-${tempCounter}`,
|
|
128
|
+
assetType:
|
|
129
|
+
assetType || suggestAssetType({ mime: file?.type, name: file?.name }),
|
|
130
|
+
name: file?.name || "",
|
|
131
|
+
ext,
|
|
132
|
+
mime: file?.type || "",
|
|
133
|
+
sizeBytes: toNumberOrNull(file?.size),
|
|
134
|
+
duration: null,
|
|
135
|
+
width: null,
|
|
136
|
+
height: null,
|
|
137
|
+
src: "",
|
|
138
|
+
// `URL.createObjectURL` deja ver el archivo sin subirlo ni codificarlo.
|
|
139
|
+
// Quien lo crea es responsable de liberarlo con `revokeAssetUrl`.
|
|
140
|
+
url: objectUrl || "",
|
|
141
|
+
poster: "",
|
|
142
|
+
retailerId: null,
|
|
143
|
+
createdAt: null,
|
|
144
|
+
file,
|
|
145
|
+
status: ASSET_STATUS.PENDING,
|
|
146
|
+
progress: 0,
|
|
147
|
+
error: null,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Identificador estable de un activo, tenga o no id del backend. */
|
|
152
|
+
export const assetKey = (asset = {}) =>
|
|
153
|
+
asset.id ?? asset.tempId ?? asset.src ?? asset.name;
|
|
154
|
+
|
|
155
|
+
export const isPersisted = (asset = {}) =>
|
|
156
|
+
asset.id !== null && asset.id !== undefined;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Modelo de interfaz -> payload del backend.
|
|
160
|
+
*
|
|
161
|
+
* Nunca incluye `file` ni `url`: el archivo ya está en S3 cuando esto se envía
|
|
162
|
+
* y la URL es derivada. Es la misma razón por la que el guardado de imágenes
|
|
163
|
+
* borra `src`/`video_src` antes del PUT — el binario en el cuerpo devuelve 413.
|
|
164
|
+
*/
|
|
165
|
+
export const toPayload = (asset = {}) => {
|
|
166
|
+
const payload = {
|
|
167
|
+
asset_type: asset.assetType,
|
|
168
|
+
name: asset.name,
|
|
169
|
+
ext: asset.ext,
|
|
170
|
+
mime: asset.mime,
|
|
171
|
+
size_bytes: asset.sizeBytes ?? null,
|
|
172
|
+
duration: asset.duration ?? null,
|
|
173
|
+
width: asset.width ?? null,
|
|
174
|
+
height: asset.height ?? null,
|
|
175
|
+
key: asset.src || null,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (isPersisted(asset)) payload.id = asset.id;
|
|
179
|
+
if (asset.retailerId !== null && asset.retailerId !== undefined) {
|
|
180
|
+
payload.id_retailer = asset.retailerId;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return payload;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Separa la lista en lo que el backend espera recibir: altas, cambios y bajas.
|
|
188
|
+
* `deleted` son ids, no objetos, porque para borrar no hace falta nada más.
|
|
189
|
+
*/
|
|
190
|
+
export const buildSavePayload = ({
|
|
191
|
+
assets = [],
|
|
192
|
+
updated = [],
|
|
193
|
+
deleted = [],
|
|
194
|
+
} = {}) => ({
|
|
195
|
+
newAssets: assets.filter((asset) => !isPersisted(asset)).map(toPayload),
|
|
196
|
+
updateAssets: updated.filter(isPersisted).map(toPayload),
|
|
197
|
+
deleteAssets: deleted
|
|
198
|
+
.map((asset) => (typeof asset === "object" ? asset.id : asset))
|
|
199
|
+
.filter(Boolean),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Qué activos ve la cadena activa: los generales (`retailerId` nulo) más los
|
|
204
|
+
* suyos.
|
|
205
|
+
*
|
|
206
|
+
* Hoy todos son generales, así que en la práctica no filtra nada. Se deja
|
|
207
|
+
* escrito y probado para el día que se pida acotar un activo a una cadena: ese
|
|
208
|
+
* día basta con que el backend mande el `id_retailer`, sin tocar la pantalla.
|
|
209
|
+
*/
|
|
210
|
+
export const filterByRetailer = (assets = [], retailerId) => {
|
|
211
|
+
if (!retailerId) return assets;
|
|
212
|
+
return assets.filter(
|
|
213
|
+
(asset) =>
|
|
214
|
+
asset.retailerId === null ||
|
|
215
|
+
asset.retailerId === undefined ||
|
|
216
|
+
Number(asset.retailerId) === Number(retailerId)
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** Libera el object URL de un activo local. Sin esto el archivo se queda en memoria. */
|
|
221
|
+
export const revokeAssetUrl = (asset = {}) => {
|
|
222
|
+
if (asset.file && asset.url && asset.url.startsWith("blob:")) {
|
|
223
|
+
URL.revokeObjectURL(asset.url);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Etiqueta legible del tipo, para badges y tablas. */
|
|
228
|
+
export const assetTypeLabel = (asset = {}) =>
|
|
229
|
+
getAssetType(asset.assetType).label;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activos de ejemplo con la forma EXACTA que devolvería el backend, para
|
|
3
|
+
* Storybook y pruebas.
|
|
4
|
+
*
|
|
5
|
+
* Están crudos a propósito, sin pasar por `fromBackend`: así, si el contrato
|
|
6
|
+
* cambia, estos objetos se editan y el adaptador se prueba de verdad en vez de
|
|
7
|
+
* comparar el modelo de interfaz contra sí mismo.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const backendAssetsMock = [
|
|
11
|
+
{
|
|
12
|
+
id: 91,
|
|
13
|
+
asset_type: "360",
|
|
14
|
+
name: "giro_producto.mp4",
|
|
15
|
+
ext: "mp4",
|
|
16
|
+
mime: "video/mp4",
|
|
17
|
+
size_bytes: 8412160,
|
|
18
|
+
duration: 12,
|
|
19
|
+
width: 1080,
|
|
20
|
+
height: 1080,
|
|
21
|
+
src: "id-136038/assets/91-6d0f9a1e-0f0c-4a2b-9f1d-2a7c1b3e5d90.mp4",
|
|
22
|
+
poster: "id-136038/assets/91-6d0f9a1e-0f0c-4a2b-9f1d-2a7c1b3e5d90.jpg",
|
|
23
|
+
id_retailer: null,
|
|
24
|
+
created_at: "2026-09-10T16:22:04.000Z",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 92,
|
|
28
|
+
asset_type: "360",
|
|
29
|
+
name: "giro_empaque.gif",
|
|
30
|
+
ext: "gif",
|
|
31
|
+
mime: "image/gif",
|
|
32
|
+
size_bytes: 3145728,
|
|
33
|
+
duration: null,
|
|
34
|
+
width: 800,
|
|
35
|
+
height: 800,
|
|
36
|
+
src: "id-136038/assets/92-b1c2d3e4-1111-2222-3333-444455556666.gif",
|
|
37
|
+
poster: null,
|
|
38
|
+
id_retailer: null,
|
|
39
|
+
created_at: "2026-09-10T16:24:41.000Z",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 93,
|
|
43
|
+
asset_type: "video",
|
|
44
|
+
name: "demostracion_uso.mp4",
|
|
45
|
+
ext: "mp4",
|
|
46
|
+
mime: "video/mp4",
|
|
47
|
+
size_bytes: 54525952,
|
|
48
|
+
duration: 96,
|
|
49
|
+
width: 1920,
|
|
50
|
+
height: 1080,
|
|
51
|
+
src: "id-136038/assets/93-77aa88bb-99cc-4dd0-8ee1-ff0011223344.mp4",
|
|
52
|
+
poster: "id-136038/assets/93-77aa88bb-99cc-4dd0-8ee1-ff0011223344.jpg",
|
|
53
|
+
// Activo de una sola cadena: sirve para probar `filterByRetailer`.
|
|
54
|
+
id_retailer: 1017,
|
|
55
|
+
created_at: "2026-09-11T09:02:17.000Z",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 94,
|
|
59
|
+
asset_type: "documento",
|
|
60
|
+
name: "manual_de_usuario.pdf",
|
|
61
|
+
ext: "pdf",
|
|
62
|
+
mime: "application/pdf",
|
|
63
|
+
size_bytes: 2418192,
|
|
64
|
+
duration: null,
|
|
65
|
+
width: null,
|
|
66
|
+
height: null,
|
|
67
|
+
src: "id-136038/assets/94-cc55dd66-7788-99aa-bbcc-ddeeff001122.pdf",
|
|
68
|
+
poster: null,
|
|
69
|
+
id_retailer: null,
|
|
70
|
+
created_at: "2026-09-12T11:48:00.000Z",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 96,
|
|
74
|
+
asset_type: "editable",
|
|
75
|
+
name: "arte_empaque.psd",
|
|
76
|
+
ext: "psd",
|
|
77
|
+
// El navegador no le asigna mime a un .psd: llega vacío. Es el caso que
|
|
78
|
+
// obliga a resolver el tipo por extensión.
|
|
79
|
+
mime: "",
|
|
80
|
+
size_bytes: 268435456,
|
|
81
|
+
duration: null,
|
|
82
|
+
width: null,
|
|
83
|
+
height: null,
|
|
84
|
+
src: "id-136038/assets/96-11223344-5566-7788-99aa-bbccddeeff00.psd",
|
|
85
|
+
poster: null,
|
|
86
|
+
id_retailer: null,
|
|
87
|
+
created_at: "2026-09-15T10:12:33.000Z",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 97,
|
|
91
|
+
asset_type: "hoja_calculo",
|
|
92
|
+
name: "tabla_de_medidas.xlsx",
|
|
93
|
+
ext: "xlsx",
|
|
94
|
+
mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
95
|
+
size_bytes: 48128,
|
|
96
|
+
duration: null,
|
|
97
|
+
width: null,
|
|
98
|
+
height: null,
|
|
99
|
+
src: "id-136038/assets/97-99887766-5544-3322-1100-ffeeddccbbaa.xlsx",
|
|
100
|
+
poster: null,
|
|
101
|
+
id_retailer: null,
|
|
102
|
+
created_at: "2026-09-15T10:14:02.000Z",
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Caso incompleto a propósito: sin `mime`, sin `ext` y sin `url`. El backend
|
|
108
|
+
* todavía no existe y es muy probable que las primeras respuestas lleguen así;
|
|
109
|
+
* el adaptador tiene que poder deducirlos del nombre y de la clave de S3.
|
|
110
|
+
*/
|
|
111
|
+
export const partialBackendAssetMock = {
|
|
112
|
+
id: 95,
|
|
113
|
+
name: "ficha_tecnica.pdf",
|
|
114
|
+
src: "id-136038/assets/95-aa11bb22-cc33-dd44-ee55-ff6677889900.pdf",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** `File` falso para probar validaciones sin tocar disco ni red. */
|
|
118
|
+
export const makeFileMock = ({
|
|
119
|
+
name = "giro_producto.mp4",
|
|
120
|
+
type = "video/mp4",
|
|
121
|
+
size = 8412160,
|
|
122
|
+
} = {}) => ({ name, type, size });
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { ASSET_ACTIONS, assetsInitialState } from "./assets.actions";
|
|
2
|
+
import { assetKey, isPersisted } from "./assets.adapter";
|
|
3
|
+
|
|
4
|
+
export { assetsInitialState };
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Estado de los activos digitales de la ficha.
|
|
8
|
+
|
|
9
|
+
Separado de `active-state` a propósito: ese reducer ya lleva 344 líneas y toda
|
|
10
|
+
la lógica de imágenes, que no comparte nada con esto —los activos no tienen
|
|
11
|
+
tipo de toma, ni obligatoriedad, ni cupo por cadena—.
|
|
12
|
+
|
|
13
|
+
Vive aquí y no en `context/reducers/` —donde están los otros cuatro— porque no
|
|
14
|
+
depende de la pantalla: es una función pura sobre activos, igual que el
|
|
15
|
+
adaptador y el catálogo. Así el hook y las historias de Storybook lo usan sin
|
|
16
|
+
arrastrar la ficha completa, y `provider-product-edition.reducer.js` solo lo
|
|
17
|
+
enchufa con una línea, igual que a los demás.
|
|
18
|
+
|
|
19
|
+
El reducer es puro. Liberar los object URL de los archivos locales es un efecto
|
|
20
|
+
y se hace en el hook antes de despachar; meterlo aquí rompería poder probar
|
|
21
|
+
este archivo sin navegador.
|
|
22
|
+
*/
|
|
23
|
+
export const assetsReducer = (state, action) => {
|
|
24
|
+
switch (action.type) {
|
|
25
|
+
case ASSET_ACTIONS.SET_ASSETS_VALUES: {
|
|
26
|
+
return {
|
|
27
|
+
...state,
|
|
28
|
+
assets_values: Array.isArray(action.payload) ? action.payload : [],
|
|
29
|
+
// Lo que venga del backend es, por definición, lo ya guardado: no hay
|
|
30
|
+
// nada pendiente de enviar.
|
|
31
|
+
updated_assets: [],
|
|
32
|
+
deleted_assets: [],
|
|
33
|
+
selected_assets: [],
|
|
34
|
+
all_assets_checked: false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
case ASSET_ACTIONS.ADD_ASSETS: {
|
|
39
|
+
const incoming = Array.isArray(action.payload)
|
|
40
|
+
? action.payload
|
|
41
|
+
: [action.payload];
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
...state,
|
|
45
|
+
assets_values: [...state.assets_values, ...incoming],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
case ASSET_ACTIONS.UPDATE_ASSET: {
|
|
50
|
+
const { key, changes } = action.payload;
|
|
51
|
+
let touched = null;
|
|
52
|
+
|
|
53
|
+
const assets_values = state.assets_values.map((asset) => {
|
|
54
|
+
if (assetKey(asset) !== key) return asset;
|
|
55
|
+
touched = asset;
|
|
56
|
+
return { ...asset, ...changes };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Un activo que todavía no existe en el backend no se "actualiza": se
|
|
60
|
+
// creará con los valores finales, así que no hay nada que rastrear.
|
|
61
|
+
const shouldTrack = touched
|
|
62
|
+
&& isPersisted(touched)
|
|
63
|
+
&& !state.updated_assets.includes(key);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
...state,
|
|
67
|
+
assets_values,
|
|
68
|
+
updated_assets: shouldTrack
|
|
69
|
+
? [...state.updated_assets, key]
|
|
70
|
+
: state.updated_assets,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case ASSET_ACTIONS.PATCH_ASSET: {
|
|
75
|
+
const { key, changes } = action.payload;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
...state,
|
|
79
|
+
assets_values: state.assets_values.map((asset) => (
|
|
80
|
+
assetKey(asset) === key ? { ...asset, ...changes } : asset
|
|
81
|
+
)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
case ASSET_ACTIONS.REMOVE_ASSET: {
|
|
86
|
+
const { key } = action.payload;
|
|
87
|
+
const target = state.assets_values.find((asset) => assetKey(asset) === key);
|
|
88
|
+
|
|
89
|
+
if (!target) return state;
|
|
90
|
+
|
|
91
|
+
const assets_values = state.assets_values.filter(
|
|
92
|
+
(asset) => assetKey(asset) !== key,
|
|
93
|
+
);
|
|
94
|
+
const selected_assets = state.selected_assets.filter(
|
|
95
|
+
(asset) => assetKey(asset) !== key,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
...state,
|
|
100
|
+
assets_values,
|
|
101
|
+
// Solo se le pide al backend que borre lo que el backend tiene. Un
|
|
102
|
+
// activo local que nunca se subió simplemente desaparece.
|
|
103
|
+
deleted_assets: isPersisted(target)
|
|
104
|
+
? [...state.deleted_assets, target.id]
|
|
105
|
+
: state.deleted_assets,
|
|
106
|
+
// Sin esto, un activo borrado seguiría contando como seleccionado y como
|
|
107
|
+
// modificado: el guardado intentaría enviar un activo que ya no existe.
|
|
108
|
+
updated_assets: state.updated_assets.filter((item) => item !== key),
|
|
109
|
+
selected_assets,
|
|
110
|
+
// Se recalcula en vez de apagarse: si estaban todos marcados y se borra
|
|
111
|
+
// uno, los que quedan siguen marcados y la casilla de "seleccionar todo"
|
|
112
|
+
// debe seguir reflejándolo.
|
|
113
|
+
all_assets_checked:
|
|
114
|
+
assets_values.length > 0
|
|
115
|
+
&& selected_assets.length === assets_values.length,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case ASSET_ACTIONS.SET_SELECTED_ASSETS: {
|
|
120
|
+
const selected = Array.isArray(action.payload) ? action.payload : [];
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
...state,
|
|
124
|
+
selected_assets: selected,
|
|
125
|
+
all_assets_checked:
|
|
126
|
+
state.assets_values.length > 0
|
|
127
|
+
&& selected.length === state.assets_values.length,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case ASSET_ACTIONS.TOGGLE_CHECK_ALL_ASSETS: {
|
|
132
|
+
// Con la lista vacía no hay nada que marcar: dejarlo en `true` pintaría la
|
|
133
|
+
// casilla de "seleccionar todo" activa sobre una bandeja sin activos.
|
|
134
|
+
const isChecked = Boolean(action.payload) && state.assets_values.length > 0;
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
...state,
|
|
138
|
+
all_assets_checked: isChecked,
|
|
139
|
+
selected_assets: isChecked ? [...state.assets_values] : [],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case ASSET_ACTIONS.CLEAR_ASSETS_TRACKING: {
|
|
144
|
+
return {
|
|
145
|
+
...state,
|
|
146
|
+
updated_assets: [],
|
|
147
|
+
deleted_assets: [],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
default: {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Los tres grupos que espera el backend, listos para `buildSavePayload`.
|
|
159
|
+
*
|
|
160
|
+
* Se deriva de `assets_values` en el momento de guardar, no se acumula: las
|
|
161
|
+
* claves rastreadas dicen QUÉ cambió y el objeto vigente dice CÓMO quedó. Es lo
|
|
162
|
+
* que evita enviar el valor anterior cuando el usuario edita dos veces el mismo
|
|
163
|
+
* activo antes de guardar.
|
|
164
|
+
*/
|
|
165
|
+
export const selectAssetsToSave = (state) => ({
|
|
166
|
+
assets: state.assets_values,
|
|
167
|
+
updated: state.assets_values.filter((asset) => state.updated_assets.includes(assetKey(asset))),
|
|
168
|
+
deleted: state.deleted_assets,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** ¿Hay algo sin guardar? Sirve para avisar antes de salir y para habilitar el botón. */
|
|
172
|
+
/**
|
|
173
|
+
* Desglose de lo que falta por guardar.
|
|
174
|
+
*
|
|
175
|
+
* Se cuenta por separado —altas, cambios, bajas— y no como un número suelto
|
|
176
|
+
* porque "3 cambios sin guardar" no le dice al usuario qué perdería si se va:
|
|
177
|
+
* no es lo mismo dejar sin guardar un borrado que un archivo recién subido.
|
|
178
|
+
*
|
|
179
|
+
* Un activo nuevo solo cuenta si ya está en S3. Uno que todavía se está
|
|
180
|
+
* subiendo, o que falló, no es un cambio pendiente de guardar: es un archivo que
|
|
181
|
+
* aún no existe, y anunciarlo como tal haría que el contador subiera y bajara
|
|
182
|
+
* solo mientras la barra de progreso avanza.
|
|
183
|
+
*/
|
|
184
|
+
export const countUnsavedAssets = (state) => {
|
|
185
|
+
const nuevos = state.assets_values.filter(
|
|
186
|
+
(asset) => !isPersisted(asset) && Boolean(asset.src),
|
|
187
|
+
).length;
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
nuevos,
|
|
191
|
+
modificados: state.updated_assets.length,
|
|
192
|
+
borrados: state.deleted_assets.length,
|
|
193
|
+
total: nuevos + state.updated_assets.length + state.deleted_assets.length,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const hasUnsavedAssets = (state) => countUnsavedAssets(state).total > 0;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subida de activos digitales a S3.
|
|
3
|
+
*
|
|
4
|
+
* El archivo NO pasa por ningún Lambda ni se convierte a base64. El backend
|
|
5
|
+
* firma la subida y el navegador manda el archivo directo al bucket. Es lo que
|
|
6
|
+
* permite subir un .psd de 300 MB: el camino que usan hoy las imágenes
|
|
7
|
+
* —`FileReader.readAsDataURL` y `putObject` con las llaves de AWS en el
|
|
8
|
+
* bundle— carga el archivo entero en memoria y lo infla un tercio.
|
|
9
|
+
*
|
|
10
|
+
* Es un POST multipart y no un PUT porque el backend firma con
|
|
11
|
+
* `createPresignedPost`, la única forma de que S3 aplique de verdad el tope de
|
|
12
|
+
* tamaño y el Content-Type. Con un PUT firmado, quien tenga la URL sube lo que
|
|
13
|
+
* quiera del tamaño que quiera.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import axios from "axios";
|
|
17
|
+
|
|
18
|
+
import { toPayload } from "./assets.adapter";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pide al backend que firme la subida de varios archivos a la vez.
|
|
22
|
+
*
|
|
23
|
+
* En una sola llamada y no una por archivo: soltar diez archivos son diez
|
|
24
|
+
* viajes de ida y vuelta que no hacen falta.
|
|
25
|
+
*
|
|
26
|
+
* Devuelve `uploads` (los que se firmaron) y `rejected` (los que el servidor no
|
|
27
|
+
* aceptó, con su motivo). El servidor valida por su cuenta aunque el front ya
|
|
28
|
+
* lo haya hecho: el front se puede saltar, y quien firma decide qué entra al
|
|
29
|
+
* bucket.
|
|
30
|
+
*/
|
|
31
|
+
export const requestPresign = async ({ articleId, assets, token }) => {
|
|
32
|
+
const { data } = await axios.post(
|
|
33
|
+
process.env.REACT_APP_ASSETS_PRESIGN_ENDPOINT,
|
|
34
|
+
{
|
|
35
|
+
articleId,
|
|
36
|
+
files: assets.map((asset) => ({
|
|
37
|
+
// El `ref` vuelve tal cual en la respuesta. Emparejar por nombre sería
|
|
38
|
+
// frágil: dos archivos que se llamen igual acabarían con la clave del
|
|
39
|
+
// otro.
|
|
40
|
+
ref: asset.tempId,
|
|
41
|
+
...toPayload(asset),
|
|
42
|
+
})),
|
|
43
|
+
},
|
|
44
|
+
{ headers: { Authorization: token } },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// `integration: lambda` de API Gateway devuelve el objeto del handler tal
|
|
48
|
+
// cual, con el cuerpo como cadena, en vez de traducirlo a una respuesta HTTP.
|
|
49
|
+
const body = typeof data?.body === "string" ? JSON.parse(data.body) : data?.body ?? data;
|
|
50
|
+
|
|
51
|
+
if (data?.statusCode && data.statusCode >= 400) {
|
|
52
|
+
throw new Error(body?.message || "No se pudo preparar la subida");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { uploads: body?.uploads ?? [], rejected: body?.rejected ?? [] };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sube un archivo con los campos que firmó el backend.
|
|
60
|
+
*
|
|
61
|
+
* Se usa XMLHttpRequest y no fetch porque fetch todavía no sabe informar del
|
|
62
|
+
* progreso de subida. En un .psd de 300 MB la barra no es un adorno: sin ella
|
|
63
|
+
* la pantalla parece colgada durante minutos.
|
|
64
|
+
*
|
|
65
|
+
* El orden del FormData importa: S3 exige que el archivo vaya AL FINAL, después
|
|
66
|
+
* de todos los campos de la política. Si va antes, rechaza la petición.
|
|
67
|
+
*/
|
|
68
|
+
export const uploadToS3 = ({ upload, file, onProgress }) => new Promise((resolve, reject) => {
|
|
69
|
+
const form = new FormData();
|
|
70
|
+
Object.entries(upload.fields).forEach(([name, value]) => form.append(name, value));
|
|
71
|
+
form.append("file", file);
|
|
72
|
+
|
|
73
|
+
const xhr = new XMLHttpRequest();
|
|
74
|
+
xhr.open("POST", upload.url, true);
|
|
75
|
+
|
|
76
|
+
xhr.upload.onprogress = (event) => {
|
|
77
|
+
if (!event.lengthComputable) return;
|
|
78
|
+
onProgress?.(Math.round((event.loaded / event.total) * 100));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
xhr.onload = () => {
|
|
82
|
+
// S3 responde 204 sin cuerpo cuando acepta el archivo.
|
|
83
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
84
|
+
resolve(upload.key);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
/*
|
|
88
|
+
Los errores de S3 vienen en XML, no en JSON. Los dos que importan:
|
|
89
|
+
EntityTooLarge = el archivo se pasó del tope que firmamos, y AccessDenied
|
|
90
|
+
= al rol del Lambda le falta s3:PutObject sobre el prefijo, o sea que se
|
|
91
|
+
desplegó solo la función y no el servicio completo.
|
|
92
|
+
*/
|
|
93
|
+
const code = /<Code>(.*?)<\/Code>/.exec(xhr.responseText)?.[1];
|
|
94
|
+
reject(new Error(code === "EntityTooLarge"
|
|
95
|
+
? "El archivo supera el tamaño permitido."
|
|
96
|
+
: `S3 rechazó la subida (${code || xhr.status}).`));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
xhr.onerror = () => reject(new Error(
|
|
100
|
+
// Un fallo de red sin estatus casi siempre es CORS: el bucket tiene que
|
|
101
|
+
// permitir POST desde el origen de la aplicación. El navegador corta la
|
|
102
|
+
// petición antes de mandarla y no deja rastro en ningún log del servidor.
|
|
103
|
+
"No se pudo conectar con el almacenamiento. Revisa la configuración CORS del bucket.",
|
|
104
|
+
));
|
|
105
|
+
|
|
106
|
+
xhr.onabort = () => reject(new Error("Subida cancelada."));
|
|
107
|
+
|
|
108
|
+
xhr.send(form);
|
|
109
|
+
});
|