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