contentoh-components-library 21.6.22 → 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,6 +17,12 @@ import {
13
17
  List,
14
18
  AttributeList,
15
19
  Section,
20
+ Block,
21
+ Choices,
22
+ Tabs,
23
+ Paging,
24
+ ImageGrid,
25
+ AddRetailer,
16
26
  Message,
17
27
  Footer,
18
28
  } from "./styles";
@@ -68,6 +78,7 @@ export const LinkVariantsModal = ({
68
78
  activeRetailerId,
69
79
  token,
70
80
  endpoint = process.env.REACT_APP_VARIANTS_ENDPOINT,
81
+ filtersEndpoint = process.env.REACT_APP_CREATE_PRODUCTS_FILTERS,
71
82
  onClose,
72
83
  onLinked,
73
84
  }) => {
@@ -79,14 +90,65 @@ export const LinkVariantsModal = ({
79
90
  const [rejected, setRejected] = useState(null);
80
91
  const [searched, setSearched] = useState(false);
81
92
  const [selected, setSelected] = useState([]);
93
+ const [total, setTotal] = useState(0);
94
+ const [hasMore, setHasMore] = useState(false);
95
+ const [loadingMore, setLoadingMore] = useState(false);
82
96
 
83
- const [attributes, setAttributes] = useState([]);
97
+ // Lo que el padre ofrece en cada dimensión, y lo que el usuario eligió de
98
+ // cada una. Se guardan por separado porque se eligen por separado: heredar
99
+ // todos los atributos no dice nada sobre qué fotos quiere la variante.
100
+ const [offered, setOffered] = useState({
101
+ attributes: [],
102
+ descriptions: [],
103
+ images: [],
104
+ categoryRetailers: [],
105
+ });
106
+ const [chosen, setChosen] = useState({
107
+ attributes: [],
108
+ descriptions: [],
109
+ images: [],
110
+ categoryRetailers: [],
111
+ });
84
112
  const [nameScope, setNameScope] = useState(null);
85
- const [inheritMode, setInheritMode] = useState("all-filled");
86
- const [chosenAttributes, setChosenAttributes] = useState([]);
87
- const [inheritImages, setInheritImages] = useState(true);
88
- const [inheritDescriptions, setInheritDescriptions] = useState(true);
89
- const [inheritCategoryRetailers, setInheritCategoryRetailers] = useState(true);
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({});
136
+
137
+ // Cadenas que el padre NO tiene y el usuario decidió agregar a la variante.
138
+ const [extraRetailers, setExtraRetailers] = useState([]);
139
+ const [newRetailer, setNewRetailer] = useState("");
140
+ const [newDepartment, setNewDepartment] = useState("");
141
+ const [newCategory, setNewCategory] = useState("");
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);
90
152
 
91
153
  const [loading, setLoading] = useState(false);
92
154
  const [error, setError] = useState(null);
@@ -116,6 +178,8 @@ export const LinkVariantsModal = ({
116
178
  setSelected([]);
117
179
  setRejected(null);
118
180
  setSearched(false);
181
+ setTotal(0);
182
+ setHasMore(false);
119
183
  setError(null);
120
184
  setStep("search");
121
185
  }, [mode]);
@@ -132,6 +196,8 @@ export const LinkVariantsModal = ({
132
196
  });
133
197
  setCandidates(data.candidates || []);
134
198
  setRejected(data.rejected || null);
199
+ setTotal(data.total ?? (data.candidates || []).length);
200
+ setHasMore(!!data.hasMore);
135
201
  setSearched(true);
136
202
  } catch (err) {
137
203
  setError(readError(err));
@@ -140,6 +206,34 @@ export const LinkVariantsModal = ({
140
206
  }
141
207
  };
142
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
+
143
237
  const toggle = (id) => {
144
238
  setSelected((current) => {
145
239
  if (mode === "parent") return current[0] === id ? [] : [id];
@@ -160,9 +254,32 @@ export const LinkVariantsModal = ({
160
254
  parentArticleId,
161
255
  retailerId: activeRetailerId,
162
256
  });
163
- setAttributes(data.attributes || []);
257
+
258
+ const listas = {
259
+ attributes: data.attributes || [],
260
+ descriptions: data.descriptions || [],
261
+ images: data.images || [],
262
+ categoryRetailers: data.categoryRetailers || [],
263
+ };
264
+
265
+ setOffered(listas);
164
266
  setNameScope(data.nameScope ?? null);
165
- setChosenAttributes((data.attributes || []).map((item) => item.id));
267
+ setChosen({
268
+ attributes: [],
269
+ descriptions: [],
270
+ images: [],
271
+ categoryRetailers: [],
272
+ });
273
+ setModes({
274
+ categoryRetailers: null,
275
+ descriptions: null,
276
+ attributes: null,
277
+ images: null,
278
+ });
279
+ setExtraRetailers([]);
280
+ setPerChild({});
281
+ setApplyToAll(true);
282
+ setActiveChild(mode === "children" ? selected[0] : null);
166
283
  setStep("inherit");
167
284
  } catch (err) {
168
285
  setError(readError(err));
@@ -171,33 +288,229 @@ export const LinkVariantsModal = ({
171
288
  }
172
289
  };
173
290
 
174
- const toggleAttribute = (id) => {
175
- setChosenAttributes((current) =>
176
- current.includes(id)
177
- ? current.filter((item) => item !== id)
178
- : [...current, id]
291
+ const toggleChoice = (dimension, id) => {
292
+ setChosen((current) => {
293
+ const lista = current[dimension];
294
+ return {
295
+ ...current,
296
+ [dimension]: lista.includes(id)
297
+ ? lista.filter((item) => item !== id)
298
+ : [...lista, id],
299
+ };
300
+ });
301
+ };
302
+
303
+ const chooseMode = (dimension, modo, ids) => {
304
+ setModes((current) => ({ ...current, [dimension]: modo }));
305
+ setChosen((current) => ({
306
+ ...current,
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 : [],
310
+ }));
311
+ };
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
+
366
+ const addRetailer = () => {
367
+ const retailerId = Number(newRetailer);
368
+ const categoryId = Number(newCategory);
369
+ if (!retailerId || !categoryId) return;
370
+
371
+ const clave = `${retailerId}-${categoryId}`;
372
+ const yaEsta =
373
+ offered.categoryRetailers.some(
374
+ (item) => `${item.retailerId}-${item.categoryId}` === clave
375
+ ) || extraRetailers.some((item) => item.clave === clave);
376
+
377
+ if (yaEsta) return;
378
+
379
+ const retailer = filters.retailers.find(
380
+ (item) => Number(item.value) === retailerId
381
+ );
382
+ const category = filters.categories.find(
383
+ (item) => Number(item.value) === categoryId
179
384
  );
385
+
386
+ setExtraRetailers((current) => [
387
+ ...current,
388
+ {
389
+ clave,
390
+ retailerId,
391
+ categoryId,
392
+ retailerName: retailer?.label ?? `Cadena ${retailerId}`,
393
+ categoryName: category?.label ?? `Categoría ${categoryId}`,
394
+ },
395
+ ]);
396
+ setChosen((current) => ({
397
+ ...current,
398
+ categoryRetailers: [...current.categoryRetailers, clave],
399
+ }));
400
+ setNewRetailer("");
401
+ setNewDepartment("");
402
+ setNewCategory("");
403
+ };
404
+
405
+ /*
406
+ Convierte una selección de pantalla en el bloque `inherit` de la API.
407
+
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 = [
417
+ ...offered.categoryRetailers.map((item) => ({
418
+ clave: `${item.retailerId}-${item.categoryId}`,
419
+ retailerId: item.retailerId,
420
+ categoryId: item.categoryId,
421
+ })),
422
+ ...(seleccion.extraRetailers ?? []),
423
+ ];
424
+
425
+ return {
426
+ attributes:
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))
437
+ .map(({ retailerId, categoryId }) => ({ retailerId, categoryId })),
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);
180
472
  };
181
473
 
182
474
  const submit = async () => {
183
475
  setLoading(true);
184
476
  setError(null);
185
477
 
186
- const inherit = {
187
- attributes: inheritMode === "all-filled" ? "all-filled" : chosenAttributes,
188
- images: inheritImages,
189
- descriptions: inheritDescriptions,
190
- categoryRetailers: inheritCategoryRetailers,
191
- };
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;
192
492
 
193
493
  const body =
194
494
  mode === "parent"
195
495
  ? { mode, articleId, parentArticleId: selected[0], inherit }
196
- : { mode, parentArticleId: articleId, childrenArticleIds: selected, inherit };
496
+ : {
497
+ mode,
498
+ parentArticleId: articleId,
499
+ childrenArticleIds: selected,
500
+ inherit,
501
+ ...(inheritByChild ? { inheritByChild } : {}),
502
+ };
197
503
 
198
504
  try {
199
505
  const data = await request("link", body);
200
- onLinked?.(data);
506
+ // El modo importa para quien escucha: en `children` este producto GANA
507
+ // variantes; en `parent` pasa a SER una. Son efectos opuestos sobre la
508
+ // misma ficha, así que quien refresque el estado necesita distinguirlos.
509
+ onLinked?.({
510
+ ...data,
511
+ mode,
512
+ parentArticleId: mode === "parent" ? selected[0] : articleId,
513
+ });
201
514
  onClose?.();
202
515
  } catch (err) {
203
516
  setError(readError(err));
@@ -206,6 +519,53 @@ export const LinkVariantsModal = ({
206
519
  }
207
520
  };
208
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
+
209
569
  const selectionLabel =
210
570
  mode === "parent"
211
571
  ? "Se elige un solo producto padre."
@@ -308,112 +668,450 @@ export const LinkVariantsModal = ({
308
668
  ))}
309
669
  </List>
310
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
+ )}
311
686
  </>
312
687
  )}
313
688
 
314
689
  {step === "inherit" && (
315
690
  <>
316
- <Section>
317
- <h3>¿Qué se hereda del producto padre?</h3>
318
- <label className="choice">
319
- <input
320
- type="radio"
321
- checked={inheritMode === "all-filled"}
322
- onChange={() => setInheritMode("all-filled")}
323
- />
324
- <span>
325
- Todos los atributos con valor
326
- <span className="hint">
327
- {attributes.length} atributo(s) con información en 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>
328
707
  </span>
329
- </span>
330
- </label>
331
- <label className="choice">
332
- <input
333
- type="radio"
334
- checked={inheritMode === "selected"}
335
- onChange={() => setInheritMode("selected")}
336
- />
337
- <span>
338
- Elegir cuáles
339
- <span className="hint">
340
- Lo que no elijas conserva el valor que ya tiene la variante.
341
- </span>
342
- </span>
343
- </label>
344
- </Section>
708
+ </label>
345
709
 
346
- {inheritMode === "selected" && activeRetailerId && !nameScope && (
347
- <Message>
348
- El producto padre no comparte la cadena que tienes abierta, así
349
- que los nombres de abajo son los de otra de sus cadenas.
350
- </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
+ </>
351
736
  )}
352
737
 
353
- {inheritMode === "selected" && (
354
- <AttributeList>
355
- {attributes.map((attribute) => (
356
- <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}>
357
794
  <label>
358
795
  <input
359
796
  type="checkbox"
360
- checked={chosenAttributes.includes(attribute.id)}
361
- onChange={() => toggleAttribute(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=""
807
+ />
808
+ <span>
809
+ {item.retailerName}
810
+ <span className="meta">
811
+ {item.categoryName || "Sin categoría"}
812
+ {!item.delPadre && " · no la tiene el padre"}
813
+ </span>
814
+ </span>
815
+ </label>
816
+ </li>
817
+ ))}
818
+ </List>
819
+
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>
867
+ <button
868
+ type="button"
869
+ onClick={addRetailer}
870
+ disabled={!newRetailer || !newCategory || loadingFilters}
871
+ >
872
+ {loadingFilters ? "…" : "Agregar"}
873
+ </button>
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>
919
+
920
+ <AttributeList>
921
+ {offered.descriptions.map((item) => (
922
+ <li key={item.id}>
923
+ <label>
924
+ <input
925
+ type="checkbox"
926
+ disabled={modes.descriptions === "all"}
927
+ checked={chosen.descriptions.includes(item.id)}
928
+ onChange={() => toggleChoice("descriptions", item.id)}
929
+ />
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>
939
+ <span className="value" title={item.value}>
940
+ {item.value}
941
+ </span>
942
+ </label>
943
+ </li>
944
+ ))}
945
+ </AttributeList>
946
+ </>
947
+ ) : (
948
+ <Message>
949
+ El producto padre no tiene descripciones que heredar.
950
+ </Message>
951
+ )}
952
+ </Block>
953
+
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", [])}
362
986
  />
363
987
  <span>
364
- {attribute.name}
365
- {attribute.unconfigured && (
366
- <span className="note">
367
- sin configuración en las cadenas del padre
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
+ )}
368
1026
  </span>
369
- )}
370
- {attribute.ambiguousName && (
371
- <span className="note">
372
- se llama distinto en otra cadena
1027
+ <span className="value" title={attribute.value}>
1028
+ {attribute.value}
373
1029
  </span>
374
- )}
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>
375
1066
  </span>
376
- <span className="value" title={attribute.value}>
377
- {attribute.value}
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>
378
1079
  </span>
379
1080
  </label>
380
- </li>
381
- ))}
382
- </AttributeList>
383
- )}
1081
+ </Choices>
384
1082
 
385
- <Section>
386
- <h3>También se hereda</h3>
387
- <label className="choice">
388
- <input
389
- type="checkbox"
390
- checked={inheritImages}
391
- onChange={() => setInheritImages((value) => !value)}
392
- />
393
- <span>Imágenes</span>
394
- </label>
395
- <label className="choice">
396
- <input
397
- type="checkbox"
398
- checked={inheritDescriptions}
399
- onChange={() => setInheritDescriptions((value) => !value)}
400
- />
401
- <span>Descripciones</span>
402
- </label>
403
- <label className="choice">
404
- <input
405
- type="checkbox"
406
- checked={inheritCategoryRetailers}
407
- onChange={() => setInheritCategoryRetailers((value) => !value)}
408
- />
409
- <span>
410
- Cadenas y categorías
411
- <span className="hint">
412
- Las parejas cadena-categoría del padre se copian a la variante.
413
- </span>
414
- </span>
415
- </label>
416
- </Section>
1083
+ <ImageGrid>
1084
+ {offered.images.map((image) => (
1085
+ <label
1086
+ key={image.id}
1087
+ className={[
1088
+ chosen.images.includes(image.id) ? "picked" : "",
1089
+ modes.images === "all" ? "locked" : "",
1090
+ ]
1091
+ .filter(Boolean)
1092
+ .join(" ")}
1093
+ >
1094
+ <input
1095
+ type="checkbox"
1096
+ disabled={modes.images === "all"}
1097
+ checked={chosen.images.includes(image.id)}
1098
+ onChange={() => toggleChoice("images", image.id)}
1099
+ />
1100
+ <img
1101
+ src={getProductImage(image.src)}
1102
+ alt={image.imageType || "Imagen del producto"}
1103
+ />
1104
+ <span>{image.imageType || "Sin tipo de toma"}</span>
1105
+ </label>
1106
+ ))}
1107
+ </ImageGrid>
1108
+ </>
1109
+ ) : (
1110
+ <Message>
1111
+ El producto padre no tiene imágenes que heredar.
1112
+ </Message>
1113
+ )}
1114
+ </Block>
417
1115
 
418
1116
  {/* Es la diferencia entre link y propagate, y es la que sorprende:
419
1117
  aquí lo elegido se escribe encima aunque la variante lo hubiera
@@ -428,7 +1126,13 @@ export const LinkVariantsModal = ({
428
1126
 
429
1127
  <Footer>
430
1128
  <span className="summary">
431
- {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(", ")}.`}
432
1136
  </span>
433
1137
  <div className="actions">
434
1138
  {step === "inherit" && (
@@ -458,15 +1162,7 @@ export const LinkVariantsModal = ({
458
1162
  type="button"
459
1163
  className="primary"
460
1164
  onClick={submit}
461
- disabled={
462
- loading ||
463
- // Solo estorba cuando hay algo que elegir y no se eligió nada.
464
- // Con el padre sin atributos heredables, ligar sigue siendo
465
- // válido: se heredan imágenes, descripciones y cadenas.
466
- (inheritMode === "selected" &&
467
- attributes.length > 0 &&
468
- !chosenAttributes.length)
469
- }
1165
+ disabled={loading || !todoDecidido}
470
1166
  >
471
1167
  {loading ? "Ligando…" : "Ligar"}
472
1168
  </button>