contentoh-components-library 21.6.23 → 21.6.24

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