contentoh-components-library 21.6.23 → 21.6.25

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.
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import axios from "axios";
3
3
 
4
4
  import { readPayload, ApiError } from "./api";
5
+ import { getProductImage, getRetailerPicture } from "../../../_utils/helper";
5
6
 
6
7
  import {
7
8
  Overlay,
@@ -13,7 +14,10 @@ import {
13
14
  List,
14
15
  AttributeList,
15
16
  Section,
16
- SectionHead,
17
+ Block,
18
+ Choices,
19
+ Tabs,
20
+ Paging,
17
21
  ImageGrid,
18
22
  AddRetailer,
19
23
  Message,
@@ -69,10 +73,9 @@ export const LinkVariantsModal = ({
69
73
  articleName,
70
74
  articleUpc,
71
75
  activeRetailerId,
72
- retailersAvailable,
73
- categoriesAvailable,
74
76
  token,
75
77
  endpoint = process.env.REACT_APP_VARIANTS_ENDPOINT,
78
+ filtersEndpoint = process.env.REACT_APP_CREATE_PRODUCTS_FILTERS,
76
79
  onClose,
77
80
  onLinked,
78
81
  }) => {
@@ -84,6 +87,9 @@ export const LinkVariantsModal = ({
84
87
  const [rejected, setRejected] = useState(null);
85
88
  const [searched, setSearched] = useState(false);
86
89
  const [selected, setSelected] = useState([]);
90
+ const [total, setTotal] = useState(0);
91
+ const [hasMore, setHasMore] = useState(false);
92
+ const [loadingMore, setLoadingMore] = useState(false);
87
93
 
88
94
  // Lo que el padre ofrece en cada dimensión, y lo que el usuario eligió de
89
95
  // cada una. Se guardan por separado porque se eligen por separado: heredar
@@ -101,14 +107,62 @@ export const LinkVariantsModal = ({
101
107
  categoryRetailers: [],
102
108
  });
103
109
  const [nameScope, setNameScope] = useState(null);
104
- const [inheritMode, setInheritMode] = useState("all-filled");
105
- const [openSection, setOpenSection] = useState("attributes");
110
+
111
+ // Un modo por dimensión, y ninguno decidido de entrada: el usuario tiene que
112
+ // pasar por las cuatro antes de poder ligar. Preseleccionar "todo" hacía que
113
+ // se pudiera ligar sin haber mirado nada, que es justo lo que hay que evitar
114
+ // en una operación que sobrescribe valores capturados.
115
+ const [modes, setModes] = useState({
116
+ categoryRetailers: null,
117
+ descriptions: null,
118
+ attributes: null,
119
+ images: null,
120
+ });
121
+
122
+ /*
123
+ Al ligar varios productos, la herencia puede ser la misma para todos o
124
+ distinta para cada uno.
125
+
126
+ Por defecto es la misma: es lo que se quiere casi siempre y evita repetir
127
+ cuatro decisiones por producto. Al desmarcarlo aparecen las pestañas y cada
128
+ producto guarda su propia selección en `perChild`.
129
+ */
130
+ // Desmarcado a propósito: aplicar la misma herencia a varios productos es una
131
+ // decisión, no un valor por omisión. Marcado de entrada se ligaban todos igual
132
+ // sin que nadie lo hubiera elegido.
133
+ const [applyToAll, setApplyToAll] = useState(false);
134
+ const [activeChild, setActiveChild] = useState(null);
135
+ const [perChild, setPerChild] = useState({});
106
136
 
107
137
  // Cadenas que el padre NO tiene y el usuario decidió agregar a la variante.
138
+ /*
139
+ Imágenes que la base lista pero cuyo archivo no está en S3.
140
+
141
+ Pasa: hay filas de rel_article_image con un src válido que apunta a un objeto
142
+ que ya no existe —comprobado, CloudFront responde 404—. Sin esto el paso 4
143
+ muestra una tarjeta rota, con el texto alternativo donde debería ir la foto, y
144
+ deja marcar para heredar algo que no se puede heredar.
145
+
146
+ No se puede saber desde el servidor sin ir a S3 por cada imagen, así que se
147
+ detecta al fallar la carga y se descuenta de la lista y del conteo.
148
+ */
149
+ const [brokenImages, setBrokenImages] = useState([]);
150
+
108
151
  const [extraRetailers, setExtraRetailers] = useState([]);
109
152
  const [newRetailer, setNewRetailer] = useState("");
153
+ const [newDepartment, setNewDepartment] = useState("");
110
154
  const [newCategory, setNewCategory] = useState("");
111
155
 
156
+ // Las categorías no son una lista plana: dependen de la cadena y del
157
+ // departamento. Es la misma cascada que usa el alta de productos, y se sirve
158
+ // del mismo endpoint para no acabar con dos árboles de categorías distintos.
159
+ const [filters, setFilters] = useState({
160
+ retailers: [],
161
+ departments: [],
162
+ categories: [],
163
+ });
164
+ const [loadingFilters, setLoadingFilters] = useState(false);
165
+
112
166
  const [loading, setLoading] = useState(false);
113
167
  const [error, setError] = useState(null);
114
168
 
@@ -137,6 +191,8 @@ export const LinkVariantsModal = ({
137
191
  setSelected([]);
138
192
  setRejected(null);
139
193
  setSearched(false);
194
+ setTotal(0);
195
+ setHasMore(false);
140
196
  setError(null);
141
197
  setStep("search");
142
198
  }, [mode]);
@@ -153,6 +209,8 @@ export const LinkVariantsModal = ({
153
209
  });
154
210
  setCandidates(data.candidates || []);
155
211
  setRejected(data.rejected || null);
212
+ setTotal(data.total ?? (data.candidates || []).length);
213
+ setHasMore(!!data.hasMore);
156
214
  setSearched(true);
157
215
  } catch (err) {
158
216
  setError(readError(err));
@@ -161,6 +219,34 @@ export const LinkVariantsModal = ({
161
219
  }
162
220
  };
163
221
 
222
+ /*
223
+ Trae la siguiente página y la agrega a la lista.
224
+
225
+ Se acumula en vez de reemplazar porque la selección vive en esta pantalla: si
226
+ la página 2 borrara la 1, lo marcado antes desaparecería de la vista sin
227
+ dejar de estar seleccionado, y el usuario acabaría ligando productos que ya
228
+ no ve.
229
+ */
230
+ const loadMore = async () => {
231
+ setLoadingMore(true);
232
+ setError(null);
233
+ try {
234
+ const data = await request("search", {
235
+ mode,
236
+ articleId,
237
+ query: query.trim(),
238
+ offset: candidates.length,
239
+ });
240
+ setCandidates((current) => [...current, ...(data.candidates || [])]);
241
+ setTotal(data.total ?? total);
242
+ setHasMore(!!data.hasMore);
243
+ } catch (err) {
244
+ setError(readError(err));
245
+ } finally {
246
+ setLoadingMore(false);
247
+ }
248
+ };
249
+
164
250
  const toggle = (id) => {
165
251
  setSelected((current) => {
166
252
  if (mode === "parent") return current[0] === id ? [] : [id];
@@ -191,17 +277,23 @@ export const LinkVariantsModal = ({
191
277
 
192
278
  setOffered(listas);
193
279
  setNameScope(data.nameScope ?? null);
194
- // Todo preseleccionado: heredar es el caso normal, y quitar lo que sobra
195
- // es menos trabajo que marcar de cero lo que casi siempre se quiere.
196
280
  setChosen({
197
- attributes: listas.attributes.map((item) => item.id),
198
- descriptions: listas.descriptions.map((item) => item.id),
199
- images: listas.images.map((item) => item.id),
200
- categoryRetailers: listas.categoryRetailers.map(
201
- (item) => `${item.retailerId}-${item.categoryId}`
202
- ),
281
+ attributes: [],
282
+ descriptions: [],
283
+ images: [],
284
+ categoryRetailers: [],
285
+ });
286
+ setModes({
287
+ categoryRetailers: null,
288
+ descriptions: null,
289
+ attributes: null,
290
+ images: null,
203
291
  });
204
292
  setExtraRetailers([]);
293
+ setBrokenImages([]);
294
+ setPerChild({});
295
+ setApplyToAll(false);
296
+ setActiveChild(mode === "children" ? selected[0] : null);
205
297
  setStep("inherit");
206
298
  } catch (err) {
207
299
  setError(readError(err));
@@ -222,13 +314,77 @@ export const LinkVariantsModal = ({
222
314
  });
223
315
  };
224
316
 
225
- const toggleAll = (dimension, ids) => {
317
+ const chooseMode = (dimension, modo, ids) => {
318
+ setModes((current) => ({ ...current, [dimension]: modo }));
226
319
  setChosen((current) => ({
227
320
  ...current,
228
- [dimension]: current[dimension].length === ids.length ? [] : ids,
321
+ // "Todos" marca la lista completa para que se vea qué va a bajar; al pasar
322
+ // a "elegir" se parte de cero, que es lo que la palabra promete.
323
+ [dimension]: modo === "all" ? ids : [],
229
324
  }));
230
325
  };
231
326
 
327
+ const loadFilters = useCallback(
328
+ async (retailer, department) => {
329
+ setLoadingFilters(true);
330
+ try {
331
+ const res = await axios.get(
332
+ `${filtersEndpoint}?retailerSelected=${
333
+ retailer ?? ""
334
+ }&departmentSelected=${department ?? ""}`,
335
+ { headers: { Authorization: token } }
336
+ );
337
+
338
+ // readPayload YA devuelve el cuerpo parseado. Volver a buscar `body`
339
+ // dentro dejaba la lista siempre vacía, y por eso el selector de cadenas
340
+ // no mostraba nada.
341
+ const lista = readPayload(res)?.data ?? [];
342
+
343
+ setFilters((current) => {
344
+ if (!retailer) {
345
+ return {
346
+ ...current,
347
+ retailers: lista.map((item) => ({
348
+ value: item.id,
349
+ label: item.name,
350
+ })),
351
+ };
352
+ }
353
+ if (!department) {
354
+ return {
355
+ ...current,
356
+ departments: lista.map((item) => ({
357
+ value: item.category,
358
+ label: item.category,
359
+ })),
360
+ categories: [],
361
+ };
362
+ }
363
+ return {
364
+ ...current,
365
+ categories: lista.map((item) => ({
366
+ value: item.id,
367
+ label: item.name,
368
+ })),
369
+ };
370
+ });
371
+ } catch (error) {
372
+ console.error("linkVariants/filters: ", error);
373
+ } finally {
374
+ setLoadingFilters(false);
375
+ }
376
+ },
377
+ [filtersEndpoint, token]
378
+ );
379
+
380
+ // Las cadenas del catálogo se piden al entrar al paso de herencia, no al abrir
381
+ // el modal: quien solo busca y cancela no llega a necesitarlas.
382
+ useEffect(() => {
383
+ if (step === "inherit" && !filters.retailers.length) {
384
+ loadFilters(null, null);
385
+ }
386
+ }, [step, filters.retailers.length, loadFilters]);
387
+
232
388
  const addRetailer = () => {
233
389
  const retailerId = Number(newRetailer);
234
390
  const categoryId = Number(newCategory);
@@ -242,11 +398,11 @@ export const LinkVariantsModal = ({
242
398
 
243
399
  if (yaEsta) return;
244
400
 
245
- const retailer = (retailersAvailable || []).find(
246
- (item) => Number(item.id ?? item.value) === retailerId
401
+ const retailer = filters.retailers.find(
402
+ (item) => Number(item.value) === retailerId
247
403
  );
248
- const category = (categoriesAvailable || []).find(
249
- (item) => Number(item.id ?? item.value) === categoryId
404
+ const category = filters.categories.find(
405
+ (item) => Number(item.value) === categoryId
250
406
  );
251
407
 
252
408
  setExtraRetailers((current) => [
@@ -255,8 +411,8 @@ export const LinkVariantsModal = ({
255
411
  clave,
256
412
  retailerId,
257
413
  categoryId,
258
- retailerName: retailer?.name ?? retailer?.label ?? `Cadena ${retailerId}`,
259
- categoryName: category?.name ?? category?.label ?? `Categoría ${categoryId}`,
414
+ retailerName: retailer?.label ?? `Cadena ${retailerId}`,
415
+ categoryName: category?.label ?? `Categoría ${categoryId}`,
260
416
  },
261
417
  ]);
262
418
  setChosen((current) => ({
@@ -264,38 +420,117 @@ export const LinkVariantsModal = ({
264
420
  categoryRetailers: [...current.categoryRetailers, clave],
265
421
  }));
266
422
  setNewRetailer("");
423
+ setNewDepartment("");
267
424
  setNewCategory("");
268
425
  };
269
426
 
270
- const submit = async () => {
271
- setLoading(true);
272
- setError(null);
427
+ /*
428
+ Convierte una selección de pantalla en el bloque `inherit` de la API.
273
429
 
274
- // Las parejas cadena+categoría viajan como objetos, no como ids: pueden ser
275
- // cadenas que el padre no tiene, y ahí no hay nada que "heredar" — se asigna.
276
- const todasLasParejas = [
430
+ Con el modo en "todos" se manda la palabra y no la lista que se pintó: si
431
+ entre la carga del modal y el guardado alguien agregó un valor al padre,
432
+ "todos" sigue significando todos. La lista sería una foto ya vieja.
433
+
434
+ Las parejas cadena+categoría viajan como objetos, no como ids, porque pueden
435
+ ser cadenas que el padre no tiene: ahí no hay nada que heredar, se asigna.
436
+ */
437
+ // Las que de verdad se pueden heredar: la base puede listar filas cuyo archivo
438
+ // ya no existe, y esas no cuentan ni para la lista ni para el "todas".
439
+ const imagenesUtiles = offered.images.filter(
440
+ (image) => !brokenImages.includes(image.id)
441
+ );
442
+
443
+ const buildInherit = (seleccion) => {
444
+ const parejas = [
277
445
  ...offered.categoryRetailers.map((item) => ({
278
446
  clave: `${item.retailerId}-${item.categoryId}`,
279
447
  retailerId: item.retailerId,
280
448
  categoryId: item.categoryId,
281
449
  })),
282
- ...extraRetailers,
450
+ ...(seleccion.extraRetailers ?? []),
283
451
  ];
284
452
 
285
- const inherit = {
453
+ return {
286
454
  attributes:
287
- inheritMode === "all-filled" ? "all-filled" : chosen.attributes,
288
- descriptions: chosen.descriptions,
289
- images: chosen.images,
290
- categoryRetailers: todasLasParejas
291
- .filter((par) => chosen.categoryRetailers.includes(par.clave))
455
+ seleccion.modes.attributes === "all"
456
+ ? "all-filled"
457
+ : seleccion.chosen.attributes,
458
+ descriptions:
459
+ seleccion.modes.descriptions === "all"
460
+ ? "all"
461
+ : seleccion.chosen.descriptions,
462
+ images:
463
+ seleccion.modes.images === "all" ? "all" : seleccion.chosen.images,
464
+ categoryRetailers: parejas
465
+ .filter((par) => seleccion.chosen.categoryRetailers.includes(par.clave))
292
466
  .map(({ retailerId, categoryId }) => ({ retailerId, categoryId })),
293
467
  };
468
+ };
469
+
470
+ const seleccionVacia = () => ({
471
+ modes: {
472
+ categoryRetailers: null,
473
+ descriptions: null,
474
+ attributes: null,
475
+ images: null,
476
+ },
477
+ chosen: {
478
+ attributes: [],
479
+ descriptions: [],
480
+ images: [],
481
+ categoryRetailers: [],
482
+ },
483
+ extraRetailers: [],
484
+ });
485
+
486
+ /*
487
+ Cambia de pestaña conservando lo elegido en la anterior. Sin esto, moverse
488
+ entre productos borraría el trabajo hecho en cada uno.
489
+ */
490
+ const switchChild = (id) => {
491
+ setPerChild((current) => ({
492
+ ...current,
493
+ [activeChild]: { modes, chosen, extraRetailers },
494
+ }));
495
+
496
+ const guardado = perChild[id] ?? seleccionVacia();
497
+ setModes(guardado.modes);
498
+ setChosen(guardado.chosen);
499
+ setExtraRetailers(guardado.extraRetailers ?? []);
500
+ setActiveChild(id);
501
+ };
502
+
503
+ const submit = async () => {
504
+ setLoading(true);
505
+ setError(null);
506
+
507
+ const inherit = buildInherit({ modes, chosen, extraRetailers });
508
+
509
+ // Con las pestañas abiertas cada producto manda la suya. La selección de la
510
+ // pestaña a la vista todavía no está en perChild, así que se agrega aquí: si
511
+ // no, lo último que tocó el usuario se perdería al guardar.
512
+ const inheritByChild =
513
+ !applyToAll && mode === "children"
514
+ ? selected.reduce((acc, id) => {
515
+ const guardado =
516
+ id === activeChild
517
+ ? { modes, chosen, extraRetailers }
518
+ : perChild[id];
519
+ if (guardado) acc[id] = buildInherit(guardado);
520
+ return acc;
521
+ }, {})
522
+ : undefined;
294
523
 
295
524
  const body =
296
525
  mode === "parent"
297
526
  ? { mode, articleId, parentArticleId: selected[0], inherit }
298
- : { mode, parentArticleId: articleId, childrenArticleIds: selected, inherit };
527
+ : {
528
+ mode,
529
+ parentArticleId: articleId,
530
+ childrenArticleIds: selected,
531
+ inherit,
532
+ ...(inheritByChild ? { inheritByChild } : {}),
533
+ };
299
534
 
300
535
  try {
301
536
  const data = await request("link", body);
@@ -315,6 +550,63 @@ export const LinkVariantsModal = ({
315
550
  }
316
551
  };
317
552
 
553
+ // Una dimensión está decidida si el usuario eligió un modo, o si el padre no
554
+ // ofrece nada en ella —no se puede pedir que decida sobre una lista vacía—.
555
+ const decidida = (dimension) => {
556
+ if (modes[dimension] !== null) return true;
557
+ if (dimension === "categoryRetailers") {
558
+ return offered.categoryRetailers.length === 0 && extraRetailers.length === 0;
559
+ }
560
+ // Una imagen cuyo archivo no existe no es algo sobre lo que se pueda decidir.
561
+ return dimension === "images"
562
+ ? imagenesUtiles.length === 0
563
+ : offered[dimension].length === 0;
564
+ };
565
+
566
+ const DIMENSIONES = [
567
+ "categoryRetailers",
568
+ "descriptions",
569
+ "attributes",
570
+ "images",
571
+ ];
572
+
573
+ // Con pestañas, que la que estoy viendo esté completa no basta: ligar escribe
574
+ // en todos, así que todos tienen que estar decididos. Si no, un producto se
575
+ // ligaría sin heredar nada solo porque nadie abrió su pestaña.
576
+ const decididoPara = (id) => {
577
+ const guardado =
578
+ id === activeChild ? { modes, chosen, extraRetailers } : perChild[id];
579
+ if (!guardado) return false;
580
+
581
+ return DIMENSIONES.every((dimension) => {
582
+ if (guardado.modes[dimension] !== null) return true;
583
+ if (dimension === "categoryRetailers") {
584
+ return (
585
+ offered.categoryRetailers.length === 0 &&
586
+ (guardado.extraRetailers ?? []).length === 0
587
+ );
588
+ }
589
+ return dimension === "images"
590
+ ? imagenesUtiles.length === 0
591
+ : offered[dimension].length === 0;
592
+ });
593
+ };
594
+
595
+ const todoDecidido =
596
+ step === "inherit" &&
597
+ (applyToAll || mode !== "children"
598
+ ? DIMENSIONES.every(decidida)
599
+ : selected.every(decididoPara));
600
+
601
+ const faltantes = [
602
+ ["categoryRetailers", "cadenas"],
603
+ ["descriptions", "descripciones"],
604
+ ["attributes", "atributos"],
605
+ ["images", "imágenes"],
606
+ ]
607
+ .filter(([clave]) => !decidida(clave))
608
+ .map(([, nombre]) => nombre);
609
+
318
610
  const selectionLabel =
319
611
  mode === "parent"
320
612
  ? "Se elige un solo producto padre."
@@ -331,7 +623,12 @@ export const LinkVariantsModal = ({
331
623
  {articleUpc ? ` · UPC ${articleUpc}` : ""}
332
624
  </p>
333
625
  </div>
334
- <button type="button" className="close" onClick={onClose} aria-label="Cerrar">
626
+ <button
627
+ type="button"
628
+ className="close"
629
+ onClick={onClose}
630
+ aria-label="Cerrar"
631
+ >
335
632
  ×
336
633
  </button>
337
634
  </Header>
@@ -403,7 +700,9 @@ export const LinkVariantsModal = ({
403
700
  {candidate.name || `Producto ${candidate.articleId}`}
404
701
  <span className="meta">
405
702
  UPC {candidate.upc || "—"}
406
- {candidate.categoryName ? ` · ${candidate.categoryName}` : ""}
703
+ {candidate.categoryName
704
+ ? ` · ${candidate.categoryName}`
705
+ : ""}
407
706
  </span>
408
707
  </span>
409
708
  {candidate.variantsCount > 0 && (
@@ -417,129 +716,293 @@ export const LinkVariantsModal = ({
417
716
  ))}
418
717
  </List>
419
718
  )}
719
+
720
+ {/* Cuántos hay de verdad. Sin esto la lista se corta en 20 y no hay
721
+ forma de saber si eso es todo o solo el principio. */}
722
+ {searched && candidates.length > 0 && (
723
+ <Paging>
724
+ <span>
725
+ Mostrando {candidates.length} de {total}
726
+ </span>
727
+ {hasMore && (
728
+ <button
729
+ type="button"
730
+ onClick={loadMore}
731
+ disabled={loadingMore}
732
+ >
733
+ {loadingMore ? "Cargando…" : "Cargar más"}
734
+ </button>
735
+ )}
736
+ </Paging>
737
+ )}
420
738
  </>
421
739
  )}
422
740
 
423
741
  {step === "inherit" && (
424
742
  <>
425
- <Section>
426
- <h3>Atributos de la ficha técnica</h3>
427
- <label className="choice">
428
- <input
429
- type="radio"
430
- checked={inheritMode === "all-filled"}
431
- onChange={() => setInheritMode("all-filled")}
432
- />
433
- <span>
434
- Todos los que tienen valor
435
- <span className="hint">
436
- {offered.attributes.length} atributo(s) con información en
437
- el padre.
438
- </span>
439
- </span>
440
- </label>
441
- <label className="choice">
442
- <input
443
- type="radio"
444
- checked={inheritMode === "selected"}
445
- onChange={() => setInheritMode("selected")}
446
- />
447
- <span>
448
- Elegir cuáles
449
- <span className="hint">
450
- Lo que no elijas conserva el valor que ya tiene la variante.
743
+ {mode === "children" && selected.length > 1 && (
744
+ <>
745
+ <label className="apply-all">
746
+ <input
747
+ type="checkbox"
748
+ checked={applyToAll}
749
+ onChange={(event) => {
750
+ setApplyToAll(event.target.checked);
751
+ if (event.target.checked) setPerChild({});
752
+ }}
753
+ />
754
+ <span>
755
+ Aplicar lo mismo a los {selected.length} productos
756
+ <span className="hint">
757
+ Desmárcalo si alguno debe heredar algo distinto.
758
+ </span>
451
759
  </span>
452
- </span>
453
- </label>
454
- </Section>
760
+ </label>
455
761
 
456
- {inheritMode === "selected" && activeRetailerId && !nameScope && (
457
- <Message>
458
- El producto padre no comparte la cadena que tienes abierta, así
459
- que los nombres de abajo son los de otra de sus cadenas.
460
- </Message>
762
+ {!applyToAll && (
763
+ <Tabs>
764
+ {selected.map((id, indice) => {
765
+ const producto = candidates.find(
766
+ (item) => item.articleId === id
767
+ );
768
+ return (
769
+ <button
770
+ key={id}
771
+ type="button"
772
+ className={id === activeChild ? "active" : ""}
773
+ onClick={() => switchChild(id)}
774
+ >
775
+ <span className="orden">{indice + 1}</span>
776
+ {producto?.name || `Producto ${id}`}
777
+ {decididoPara(id) && (
778
+ <span className="listo" aria-label="listo">
779
+ ✓
780
+ </span>
781
+ )}
782
+ </button>
783
+ );
784
+ })}
785
+ </Tabs>
786
+ )}
787
+ </>
461
788
  )}
462
789
 
463
- {inheritMode === "selected" && (
464
- <AttributeList>
465
- {offered.attributes.map((attribute) => (
466
- <li key={attribute.id}>
790
+ {/* El orden no es casual: primero se decide en qué cadenas vive la
791
+ variante, porque de eso depende qué atributos le aplican. Las
792
+ imágenes van al final: son la decisión más visual y la que menos
793
+ arrastra a las demás. */}
794
+
795
+ <Block>
796
+ <h3>
797
+ <span className="paso">1</span> Cadenas y categorías
798
+ </h3>
799
+ <Choices>
800
+ <label>
801
+ <input
802
+ type="radio"
803
+ name={`categoryRetailers-${activeChild ?? "todos"}`}
804
+ checked={modes.categoryRetailers === "all"}
805
+ onChange={() =>
806
+ chooseMode(
807
+ "categoryRetailers",
808
+ "all",
809
+ offered.categoryRetailers.map(
810
+ (item) => `${item.retailerId}-${item.categoryId}`
811
+ )
812
+ )
813
+ }
814
+ />
815
+ <span>
816
+ Todas las del producto padre
817
+ <span className="hint">
818
+ {offered.categoryRetailers.length} cadena(s).
819
+ </span>
820
+ </span>
821
+ </label>
822
+ <label>
823
+ <input
824
+ type="radio"
825
+ name={`categoryRetailers-${activeChild ?? "todos"}`}
826
+ checked={modes.categoryRetailers === "some"}
827
+ onChange={() =>
828
+ chooseMode("categoryRetailers", "some", [])
829
+ }
830
+ />
831
+ <span>
832
+ Elegir cuáles
833
+ <span className="hint">
834
+ También puedes agregar una cadena que el padre no tenga.
835
+ </span>
836
+ </span>
837
+ </label>
838
+ </Choices>
839
+
840
+ <List>
841
+ {[
842
+ ...offered.categoryRetailers.map((item) => ({
843
+ ...item,
844
+ clave: `${item.retailerId}-${item.categoryId}`,
845
+ delPadre: true,
846
+ })),
847
+ ...extraRetailers.map((item) => ({
848
+ ...item,
849
+ delPadre: false,
850
+ })),
851
+ ].map((item) => (
852
+ <li key={item.clave}>
467
853
  <label>
468
854
  <input
469
855
  type="checkbox"
470
- checked={chosen.attributes.includes(attribute.id)}
471
- onChange={() => toggleChoice("attributes", attribute.id)}
856
+ disabled={modes.categoryRetailers === "all"}
857
+ checked={chosen.categoryRetailers.includes(
858
+ item.clave
859
+ )}
860
+ onChange={() =>
861
+ toggleChoice("categoryRetailers", item.clave)
862
+ }
863
+ />
864
+ <img
865
+ className="logo"
866
+ src={getRetailerPicture(item.retailerId, 24, 24)}
867
+ alt=""
472
868
  />
473
869
  <span>
474
- {attribute.name}
475
- {attribute.unconfigured && (
476
- <span className="note">
477
- sin configuración en las cadenas del padre
478
- </span>
479
- )}
480
- {attribute.ambiguousName && (
481
- <span className="note">
482
- se llama distinto en otra cadena
483
- </span>
484
- )}
485
- </span>
486
- <span className="value" title={attribute.value}>
487
- {attribute.value}
870
+ {item.retailerName}
871
+ <span className="meta">
872
+ {item.categoryName || "Sin categoría"}
873
+ {!item.delPadre && " · no la tiene el padre"}
874
+ </span>
488
875
  </span>
489
876
  </label>
490
877
  </li>
491
878
  ))}
492
- </AttributeList>
493
- )}
879
+ </List>
494
880
 
495
- <Section>
496
- <SectionHead>
881
+ <AddRetailer>
882
+ <select
883
+ value={newRetailer}
884
+ onChange={(event) => {
885
+ const valor = event.target.value;
886
+ setNewRetailer(valor);
887
+ setNewDepartment("");
888
+ setNewCategory("");
889
+ if (valor) loadFilters(valor, null);
890
+ }}
891
+ >
892
+ <option value="">Agregar otra cadena…</option>
893
+ {filters.retailers.map((item) => (
894
+ <option key={item.value} value={item.value}>
895
+ {item.label}
896
+ </option>
897
+ ))}
898
+ </select>
899
+ <select
900
+ value={newDepartment}
901
+ disabled={!newRetailer || !filters.departments.length}
902
+ onChange={(event) => {
903
+ const valor = event.target.value;
904
+ setNewDepartment(valor);
905
+ setNewCategory("");
906
+ if (valor) loadFilters(newRetailer, valor);
907
+ }}
908
+ >
909
+ <option value="">Departamento…</option>
910
+ {filters.departments.map((item) => (
911
+ <option key={item.value} value={item.value}>
912
+ {item.label}
913
+ </option>
914
+ ))}
915
+ </select>
916
+ <select
917
+ value={newCategory}
918
+ disabled={!newDepartment || !filters.categories.length}
919
+ onChange={(event) => setNewCategory(event.target.value)}
920
+ >
921
+ <option value="">Categoría…</option>
922
+ {filters.categories.map((item) => (
923
+ <option key={item.value} value={item.value}>
924
+ {item.label}
925
+ </option>
926
+ ))}
927
+ </select>
497
928
  <button
498
929
  type="button"
499
- className="toggle"
500
- aria-expanded={openSection === "descriptions"}
501
- onClick={() =>
502
- setOpenSection(
503
- openSection === "descriptions" ? null : "descriptions"
504
- )
505
- }
930
+ onClick={addRetailer}
931
+ disabled={!newRetailer || !newCategory || loadingFilters}
506
932
  >
507
- Descripciones
508
- <span className="count">
509
- {chosen.descriptions.length} de{" "}
510
- {offered.descriptions.length}
511
- </span>
933
+ {loadingFilters ? "…" : "Agregar"}
512
934
  </button>
513
- {offered.descriptions.length > 0 && (
514
- <button
515
- type="button"
516
- className="all"
517
- onClick={() =>
518
- toggleAll(
519
- "descriptions",
520
- offered.descriptions.map((item) => item.id)
521
- )
522
- }
523
- >
524
- {chosen.descriptions.length === offered.descriptions.length
525
- ? "Ninguna"
526
- : "Todas"}
527
- </button>
528
- )}
529
- </SectionHead>
935
+ </AddRetailer>
936
+ </Block>
937
+
938
+ <Block>
939
+ <h3>
940
+ <span className="paso">2</span> Descripciones
941
+ </h3>
942
+ {offered.descriptions.length ? (
943
+ <>
944
+ <Choices>
945
+ <label>
946
+ <input
947
+ type="radio"
948
+ name={`descriptions-${activeChild ?? "todos"}`}
949
+ checked={modes.descriptions === "all"}
950
+ onChange={() =>
951
+ chooseMode(
952
+ "descriptions",
953
+ "all",
954
+ offered.descriptions.map((item) => item.id)
955
+ )
956
+ }
957
+ />
958
+ <span>
959
+ Todas las que tienen valor
960
+ <span className="hint">
961
+ {offered.descriptions.length} descripción(es) con
962
+ información en el padre.
963
+ </span>
964
+ </span>
965
+ </label>
966
+ <label>
967
+ <input
968
+ type="radio"
969
+ name={`descriptions-${activeChild ?? "todos"}`}
970
+ checked={modes.descriptions === "some"}
971
+ onChange={() =>
972
+ chooseMode("descriptions", "some", [])
973
+ }
974
+ />
975
+ <span>
976
+ Elegir cuáles
977
+ <span className="hint">
978
+ Lo que no elijas conserva el valor que ya tiene la
979
+ variante.
980
+ </span>
981
+ </span>
982
+ </label>
983
+ </Choices>
530
984
 
531
- {openSection === "descriptions" &&
532
- (offered.descriptions.length ? (
533
985
  <AttributeList>
534
986
  {offered.descriptions.map((item) => (
535
987
  <li key={item.id}>
536
988
  <label>
537
989
  <input
538
990
  type="checkbox"
991
+ disabled={modes.descriptions === "all"}
539
992
  checked={chosen.descriptions.includes(item.id)}
540
- onChange={() => toggleChoice("descriptions", item.id)}
993
+ onChange={() =>
994
+ toggleChoice("descriptions", item.id)
995
+ }
541
996
  />
542
- <span>{item.name}</span>
997
+ <span>
998
+ {item.name}
999
+ {item.duplicated && (
1000
+ <span className="note">
1001
+ el padre tiene {item.rows} versiones de este
1002
+ texto; se copian todas
1003
+ </span>
1004
+ )}
1005
+ </span>
543
1006
  <span className="value" title={item.value}>
544
1007
  {item.value}
545
1008
  </span>
@@ -547,167 +1010,190 @@ export const LinkVariantsModal = ({
547
1010
  </li>
548
1011
  ))}
549
1012
  </AttributeList>
550
- ) : (
551
- <Message>El producto padre no tiene descripciones.</Message>
552
- ))}
553
- </Section>
554
-
555
- <Section>
556
- <SectionHead>
557
- <button
558
- type="button"
559
- className="toggle"
560
- aria-expanded={openSection === "images"}
561
- onClick={() =>
562
- setOpenSection(openSection === "images" ? null : "images")
563
- }
564
- >
565
- Imágenes
566
- <span className="count">
567
- {chosen.images.length} de {offered.images.length}
568
- </span>
569
- </button>
570
- {offered.images.length > 0 && (
571
- <button
572
- type="button"
573
- className="all"
574
- onClick={() =>
575
- toggleAll(
576
- "images",
577
- offered.images.map((item) => item.id)
578
- )
579
- }
580
- >
581
- {chosen.images.length === offered.images.length
582
- ? "Ninguna"
583
- : "Todas"}
584
- </button>
585
- )}
586
- </SectionHead>
1013
+ </>
1014
+ ) : (
1015
+ <Message>
1016
+ El producto padre no tiene descripciones que heredar.
1017
+ </Message>
1018
+ )}
1019
+ </Block>
587
1020
 
588
- {openSection === "images" &&
589
- (offered.images.length ? (
590
- <ImageGrid>
591
- {offered.images.map((image) => (
592
- <label
593
- key={image.id}
594
- className={
595
- chosen.images.includes(image.id) ? "picked" : ""
1021
+ <Block>
1022
+ <h3>
1023
+ <span className="paso">3</span> Atributos de la ficha técnica
1024
+ </h3>
1025
+ {offered.attributes.length ? (
1026
+ <>
1027
+ <Choices>
1028
+ <label>
1029
+ <input
1030
+ type="radio"
1031
+ name={`attributes-${activeChild ?? "todos"}`}
1032
+ checked={modes.attributes === "all"}
1033
+ onChange={() =>
1034
+ chooseMode(
1035
+ "attributes",
1036
+ "all",
1037
+ offered.attributes.map((item) => item.id)
1038
+ )
596
1039
  }
597
- >
598
- <input
599
- type="checkbox"
600
- checked={chosen.images.includes(image.id)}
601
- onChange={() => toggleChoice("images", image.id)}
602
- />
603
- <img src={image.src} alt={image.imageType || ""} />
604
- <span>{image.imageType || "Sin tipo"}</span>
605
- </label>
606
- ))}
607
- </ImageGrid>
608
- ) : (
609
- <Message>El producto padre no tiene imágenes.</Message>
610
- ))}
611
- </Section>
1040
+ />
1041
+ <span>
1042
+ Todos los que tienen valor
1043
+ <span className="hint">
1044
+ {offered.attributes.length} atributo(s) con
1045
+ información en el padre.
1046
+ </span>
1047
+ </span>
1048
+ </label>
1049
+ <label>
1050
+ <input
1051
+ type="radio"
1052
+ name={`attributes-${activeChild ?? "todos"}`}
1053
+ checked={modes.attributes === "some"}
1054
+ onChange={() => chooseMode("attributes", "some", [])}
1055
+ />
1056
+ <span>
1057
+ Elegir cuáles
1058
+ <span className="hint">
1059
+ Lo que no elijas conserva el valor que ya tiene la
1060
+ variante.
1061
+ </span>
1062
+ </span>
1063
+ </label>
1064
+ </Choices>
612
1065
 
613
- <Section>
614
- <SectionHead>
615
- <button
616
- type="button"
617
- className="toggle"
618
- aria-expanded={openSection === "retailers"}
619
- onClick={() =>
620
- setOpenSection(
621
- openSection === "retailers" ? null : "retailers"
622
- )
623
- }
624
- >
625
- Cadenas y categorías
626
- <span className="count">
627
- {chosen.categoryRetailers.length} seleccionada(s)
628
- </span>
629
- </button>
630
- </SectionHead>
1066
+ {activeRetailerId && !nameScope && (
1067
+ <Message>
1068
+ El producto padre no comparte la cadena que tienes
1069
+ abierta, así que los nombres de abajo son los de otra de
1070
+ sus cadenas.
1071
+ </Message>
1072
+ )}
631
1073
 
632
- {openSection === "retailers" && (
633
- <>
634
- <List>
635
- {[
636
- ...offered.categoryRetailers.map((item) => ({
637
- ...item,
638
- clave: `${item.retailerId}-${item.categoryId}`,
639
- delPadre: true,
640
- })),
641
- ...extraRetailers.map((item) => ({
642
- ...item,
643
- delPadre: false,
644
- })),
645
- ].map((item) => (
646
- <li key={item.clave}>
1074
+ <AttributeList>
1075
+ {offered.attributes.map((attribute) => (
1076
+ <li key={attribute.id}>
647
1077
  <label>
648
1078
  <input
649
1079
  type="checkbox"
650
- checked={chosen.categoryRetailers.includes(
651
- item.clave
652
- )}
1080
+ disabled={modes.attributes === "all"}
1081
+ checked={chosen.attributes.includes(attribute.id)}
653
1082
  onChange={() =>
654
- toggleChoice("categoryRetailers", item.clave)
1083
+ toggleChoice("attributes", attribute.id)
655
1084
  }
656
1085
  />
657
1086
  <span>
658
- {item.retailerName}
659
- <span className="meta">
660
- {item.categoryName || "Sin categoría"}
661
- {!item.delPadre && " · no la tiene el padre"}
662
- </span>
1087
+ {attribute.name}
1088
+ {attribute.unconfigured && (
1089
+ <span className="note">
1090
+ sin configuración en las cadenas del padre
1091
+ </span>
1092
+ )}
1093
+ {attribute.ambiguousName && (
1094
+ <span className="note">
1095
+ se llama distinto en otra cadena
1096
+ </span>
1097
+ )}
1098
+ </span>
1099
+ <span className="value" title={attribute.value}>
1100
+ {attribute.value}
663
1101
  </span>
664
1102
  </label>
665
1103
  </li>
666
1104
  ))}
667
- </List>
668
-
669
- {/* Agregar una cadena que el padre no tiene. No es herencia:
670
- la variante entra a esa cadena por su cuenta. */}
671
- <AddRetailer>
672
- <select
673
- value={newRetailer}
674
- onChange={(event) => setNewRetailer(event.target.value)}
675
- >
676
- <option value="">Agregar otra cadena…</option>
677
- {(retailersAvailable || []).map((item) => (
678
- <option
679
- key={item.id ?? item.value}
680
- value={item.id ?? item.value}
681
- >
682
- {item.name ?? item.label}
683
- </option>
684
- ))}
685
- </select>
686
- <select
687
- value={newCategory}
688
- onChange={(event) => setNewCategory(event.target.value)}
689
- >
690
- <option value="">Categoría…</option>
691
- {(categoriesAvailable || []).map((item) => (
692
- <option
693
- key={item.id ?? item.value}
694
- value={item.id ?? item.value}
695
- >
696
- {item.name ?? item.label}
697
- </option>
698
- ))}
699
- </select>
700
- <button
701
- type="button"
702
- onClick={addRetailer}
703
- disabled={!newRetailer || !newCategory}
704
- >
705
- Agregar
706
- </button>
707
- </AddRetailer>
1105
+ </AttributeList>
1106
+ </>
1107
+ ) : (
1108
+ <Message>
1109
+ El producto padre no tiene atributos con valor que heredar.
1110
+ </Message>
1111
+ )}
1112
+ </Block>
1113
+
1114
+ <Block>
1115
+ <h3>
1116
+ <span className="paso">4</span> Imágenes
1117
+ </h3>
1118
+ {imagenesUtiles.length ? (
1119
+ <>
1120
+ <Choices>
1121
+ <label>
1122
+ <input
1123
+ type="radio"
1124
+ name={`images-${activeChild ?? "todos"}`}
1125
+ checked={modes.images === "all"}
1126
+ onChange={() =>
1127
+ chooseMode(
1128
+ "images",
1129
+ "all",
1130
+ imagenesUtiles.map((item) => item.id)
1131
+ )
1132
+ }
1133
+ />
1134
+ <span>
1135
+ Todas
1136
+ <span className="hint">
1137
+ {imagenesUtiles.length} imagen(es) en el padre.
1138
+ </span>
1139
+ </span>
1140
+ </label>
1141
+ <label>
1142
+ <input
1143
+ type="radio"
1144
+ name={`images-${activeChild ?? "todos"}`}
1145
+ checked={modes.images === "some"}
1146
+ onChange={() => chooseMode("images", "some", [])}
1147
+ />
1148
+ <span>
1149
+ Elegir cuáles
1150
+ <span className="hint">
1151
+ Útil cuando la variante cambia de color o de
1152
+ empaque.
1153
+ </span>
1154
+ </span>
1155
+ </label>
1156
+ </Choices>
1157
+
1158
+ <ImageGrid>
1159
+ {imagenesUtiles.map((image) => (
1160
+ <label
1161
+ key={image.id}
1162
+ className={[
1163
+ chosen.images.includes(image.id) ? "picked" : "",
1164
+ modes.images === "all" ? "locked" : "",
1165
+ ]
1166
+ .filter(Boolean)
1167
+ .join(" ")}
1168
+ >
1169
+ <input
1170
+ type="checkbox"
1171
+ disabled={modes.images === "all"}
1172
+ checked={chosen.images.includes(image.id)}
1173
+ onChange={() => toggleChoice("images", image.id)}
1174
+ />
1175
+ <img
1176
+ src={getProductImage(image.src)}
1177
+ alt=""
1178
+ onError={() =>
1179
+ setBrokenImages((current) =>
1180
+ current.includes(image.id)
1181
+ ? current
1182
+ : [...current, image.id]
1183
+ )
1184
+ }
1185
+ />
1186
+ <span>{image.imageType || "Sin tipo de toma"}</span>
1187
+ </label>
1188
+ ))}
1189
+ </ImageGrid>
708
1190
  </>
1191
+ ) : (
1192
+ <Message>
1193
+ El producto padre no tiene imágenes que heredar.
1194
+ </Message>
709
1195
  )}
710
- </Section>
1196
+ </Block>
711
1197
 
712
1198
  {/* Es la diferencia entre link y propagate, y es la que sorprende:
713
1199
  aquí lo elegido se escribe encima aunque la variante lo hubiera
@@ -722,7 +1208,15 @@ export const LinkVariantsModal = ({
722
1208
 
723
1209
  <Footer>
724
1210
  <span className="summary">
725
- {step === "search" ? selectionLabel : "Revisa la herencia antes de ligar."}
1211
+ {step === "search"
1212
+ ? selectionLabel
1213
+ : todoDecidido
1214
+ ? "Todo revisado. Puedes ligar."
1215
+ : !applyToAll && mode === "children"
1216
+ ? `Faltan ${
1217
+ selected.filter((id) => !decididoPara(id)).length
1218
+ } producto(s) por revisar.`
1219
+ : `Falta decidir: ${faltantes.join(", ")}.`}
726
1220
  </span>
727
1221
  <div className="actions">
728
1222
  {step === "inherit" && (
@@ -735,7 +1229,12 @@ export const LinkVariantsModal = ({
735
1229
  Atrás
736
1230
  </button>
737
1231
  )}
738
- <button type="button" className="secondary" onClick={onClose} disabled={loading}>
1232
+ <button
1233
+ type="button"
1234
+ className="secondary"
1235
+ onClick={onClose}
1236
+ disabled={loading}
1237
+ >
739
1238
  Cancelar
740
1239
  </button>
741
1240
  {step === "search" ? (
@@ -752,15 +1251,7 @@ export const LinkVariantsModal = ({
752
1251
  type="button"
753
1252
  className="primary"
754
1253
  onClick={submit}
755
- disabled={
756
- loading ||
757
- // Solo estorba cuando hay algo que elegir y no se eligió nada.
758
- // Con el padre sin atributos heredables, ligar sigue siendo
759
- // válido: se heredan imágenes, descripciones y cadenas.
760
- (inheritMode === "selected" &&
761
- offered.attributes.length > 0 &&
762
- !chosen.attributes.length)
763
- }
1254
+ disabled={loading || !todoDecidido}
764
1255
  >
765
1256
  {loading ? "Ligando…" : "Ligar"}
766
1257
  </button>