kupos-ui-components-lib 10.4.69 → 10.4.71

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.
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+ interface BannerScrollerProps {
3
+ variant: "desktop" | "mobile";
4
+ className?: string;
5
+ style?: React.CSSProperties;
6
+ children: React.ReactNode;
7
+ }
8
+ interface BannerScrollerState {
9
+ canScrollLeft: boolean;
10
+ canScrollRight: boolean;
11
+ }
12
+ declare class BannerScroller extends React.Component<BannerScrollerProps, BannerScrollerState> {
13
+ private scrollerRef;
14
+ private resizeObserver?;
15
+ constructor(props: BannerScrollerProps);
16
+ componentDidMount(): void;
17
+ componentDidUpdate(): void;
18
+ componentWillUnmount(): void;
19
+ updateScrollState(): void;
20
+ scrollCards(direction: -1 | 1): void;
21
+ render(): React.JSX.Element;
22
+ }
23
+ export default BannerScroller;
@@ -0,0 +1,92 @@
1
+ "use client";
2
+ import React from "react";
3
+ // Each arrow click scrolls this share of the visible width, so the card cut off at the
4
+ // edge stays in view as context
5
+ const ARROW_SCROLL_FRACTION = 0.8;
6
+ const ARROW_LAYOUT = {
7
+ // Desktop arrows straddle the row's edges; mobile keeps them flush with the screen edge
8
+ desktop: { size: 28, icon: 14, offset: "-14px" },
9
+ mobile: { size: 24, icon: 12, offset: "0" },
10
+ };
11
+ function ArrowButton({ direction, variant, onClick, }) {
12
+ const layout = ARROW_LAYOUT[variant];
13
+ return (React.createElement("button", { type: "button", "aria-label": direction === "left" ? "Ver ofertas anteriores" : "Ver más ofertas", onClick: onClick,
14
+ // Inline so the placement doesn't depend on the host's stylesheet
15
+ style: {
16
+ position: "absolute",
17
+ top: "50%",
18
+ zIndex: 1,
19
+ display: "flex",
20
+ alignItems: "center",
21
+ justifyContent: "center",
22
+ padding: 0,
23
+ border: "none",
24
+ borderRadius: "50%",
25
+ background: "#fff",
26
+ cursor: "pointer",
27
+ [direction]: layout.offset,
28
+ width: layout.size,
29
+ height: layout.size,
30
+ transform: "translateY(-50%)",
31
+ boxShadow: "0 1px 4px rgba(0,0,0,0.25)",
32
+ } },
33
+ React.createElement("svg", { width: layout.icon, height: layout.icon, viewBox: "0 0 24 24", "aria-hidden": "true" },
34
+ React.createElement("path", { d: direction === "left" ? "M15 5l-7 7 7 7" : "M9 5l7 7-7 7", fill: "none", stroke: "#464647", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }))));
35
+ }
36
+ // Horizontal card row with left/right arrows that appear only while cards are hidden
37
+ // in that direction
38
+ class BannerScroller extends React.Component {
39
+ constructor(props) {
40
+ super(props);
41
+ this.scrollerRef = React.createRef();
42
+ this.state = { canScrollLeft: false, canScrollRight: false };
43
+ this.updateScrollState = this.updateScrollState.bind(this);
44
+ }
45
+ componentDidMount() {
46
+ this.updateScrollState();
47
+ window.addEventListener("resize", this.updateScrollState);
48
+ if (typeof ResizeObserver !== "undefined" && this.scrollerRef.current) {
49
+ this.resizeObserver = new ResizeObserver(this.updateScrollState);
50
+ this.resizeObserver.observe(this.scrollerRef.current);
51
+ }
52
+ }
53
+ // The card set can change without the row resizing
54
+ componentDidUpdate() {
55
+ this.updateScrollState();
56
+ }
57
+ componentWillUnmount() {
58
+ var _a;
59
+ window.removeEventListener("resize", this.updateScrollState);
60
+ (_a = this.resizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
61
+ }
62
+ // The 1px slack absorbs sub-pixel scroll positions
63
+ updateScrollState() {
64
+ const el = this.scrollerRef.current;
65
+ if (!el)
66
+ return;
67
+ const canScrollLeft = el.scrollLeft > 1;
68
+ const canScrollRight = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
69
+ if (canScrollLeft !== this.state.canScrollLeft ||
70
+ canScrollRight !== this.state.canScrollRight) {
71
+ this.setState({ canScrollLeft, canScrollRight });
72
+ }
73
+ }
74
+ scrollCards(direction) {
75
+ const el = this.scrollerRef.current;
76
+ if (!el)
77
+ return;
78
+ el.scrollBy({
79
+ left: direction * el.clientWidth * ARROW_SCROLL_FRACTION,
80
+ behavior: "smooth",
81
+ });
82
+ }
83
+ render() {
84
+ const { variant, className = "", style, children } = this.props;
85
+ const { canScrollLeft, canScrollRight } = this.state;
86
+ return (React.createElement("div", { style: { position: "relative" } },
87
+ canScrollLeft && (React.createElement(ArrowButton, { direction: "left", variant: variant, onClick: () => this.scrollCards(-1) })),
88
+ canScrollRight && (React.createElement(ArrowButton, { direction: "right", variant: variant, onClick: () => this.scrollCards(1) })),
89
+ React.createElement("div", { ref: this.scrollerRef, onScroll: this.updateScrollState, className: `kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`, style: style }, children)));
90
+ }
91
+ }
92
+ export default BannerScroller;
@@ -2,6 +2,7 @@
2
2
  import React from "react";
3
3
  import { CAMPAIGN_ART, DEFAULT_BACKGROUND_IMAGES, toImageUrl, } from "../../ui/DiscountBanners/defaultBackgrounds";
4
4
  import { deriveDiscountCards } from "../../ui/DiscountBanners";
5
+ import BannerScroller from "./BannerScroller";
5
6
  const DEFAULT_LABEL = "Oferta especial";
6
7
  const DEFAULT_ACCENT_PALETTE = [
7
8
  "#107E23",
@@ -14,7 +15,7 @@ const SIZE_CLASSES = {
14
15
  chip: "w-[81px] h-[17px] text-[9px] leading-[17px]",
15
16
  column: "max-w-[116px] mt-[3px]",
16
17
  discount: "text-[15px] leading-[17px]",
17
- subtitle: "text-[12px] leading-[14px] mt-[1px]",
18
+ subtitle: "text-[12px] leading-[14px] mt-[1px] max-h-[28px] overflow-hidden",
18
19
  flameBadge: "text-[18px] leading-[16px]",
19
20
  flamePercent: "text-[10px]",
20
21
  },
@@ -23,7 +24,7 @@ const SIZE_CLASSES = {
23
24
  chip: "w-[69px] h-[15px] text-[8px] leading-[15px]",
24
25
  column: "max-w-[99px] mt-[2px]",
25
26
  discount: "text-[13px] leading-[15px]",
26
- subtitle: "text-[10.5px] leading-[12px] mt-[1px]",
27
+ subtitle: "text-[10.5px] leading-[12px] mt-[1px] max-h-[24px] overflow-hidden",
27
28
  flameBadge: "text-[13.5px] leading-[13.5px]",
28
29
  flamePercent: "text-[8px]",
29
30
  },
@@ -42,14 +43,21 @@ const OPERATOR_LOGO_LAYOUT = {
42
43
  },
43
44
  flameBadge: { fontSize: "22px", lineHeight: "19px" },
44
45
  flamePercent: { fontSize: "12px" },
45
- discount: { lineHeight: "16px" },
46
+ discount: { lineHeight: "15px" },
47
+ // As wide as the logo row; long text wraps to a second line and stops there
46
48
  subtitle: {
47
49
  fontSize: "11px",
48
50
  lineHeight: "11px",
49
51
  marginTop: 0,
50
- maxWidth: "100px",
52
+ maxWidth: "128px",
53
+ maxHeight: "22px",
54
+ overflow: "hidden",
51
55
  },
52
- row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
56
+ // Tighter above the discount so a two-line subtitle still leaves the logo gap
57
+ column: { maxWidth: "128px", marginTop: 0 },
58
+ row: { gap: "3px", fontSize: "10px", marginTop: "8px", maxWidth: "128px" },
59
+ // Non-Remate cards space their logos further apart
60
+ spacedRow: { gap: "8px" },
53
61
  logo: { height: "18px", width: "84px", minWidth: 0 },
54
62
  },
55
63
  compact: {
@@ -63,19 +71,27 @@ const OPERATOR_LOGO_LAYOUT = {
63
71
  },
64
72
  flameBadge: { fontSize: "16px", lineHeight: "16px" },
65
73
  flamePercent: { fontSize: "10px" },
66
- discount: { lineHeight: "14px" },
74
+ discount: { lineHeight: "13px" },
75
+ // As wide as the logo row; long text wraps to a second line and stops there
67
76
  subtitle: {
68
77
  fontSize: "9.5px",
69
78
  lineHeight: "9.5px",
70
79
  marginTop: 0,
71
- maxWidth: "85px",
80
+ maxWidth: "108px",
81
+ maxHeight: "19px",
82
+ overflow: "hidden",
72
83
  },
73
- row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
84
+ column: { maxWidth: "108px", marginTop: 0 },
85
+ row: { gap: "2px", fontSize: "8px", marginTop: "6px", maxWidth: "108px" },
86
+ spacedRow: { gap: "6px" },
74
87
  logo: { height: "14px", width: "70px", minWidth: 0 },
75
88
  },
76
89
  };
77
90
  const MAX_VISIBLE_OPERATOR_LOGOS = 3;
78
91
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
92
+ // Lifts operator logos off the green Remate art, where light logos otherwise blend in.
93
+ // drop-shadow follows the logo's shape rather than its box.
94
+ const REMATE_LOGO_SHADOW = "drop-shadow(0 0 1px rgba(0,0,0,0.45)) drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
79
95
  const REMATE_KEY = "remate";
80
96
  function isRemateCard(card) {
81
97
  return card.isWowDeal || card.key === REMATE_KEY;
@@ -146,13 +162,15 @@ class DiscountBannersDesktop extends React.Component {
146
162
  const sizeClasses = SIZE_CLASSES[size !== null && size !== void 0 ? size : "default"];
147
163
  const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
148
164
  const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
149
- return (React.createElement("div", { className: `kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`, style: style }, cards.map((card, index) => {
165
+ return (React.createElement(BannerScroller, { variant: "desktop", className: className, style: style }, cards.map((card, index) => {
150
166
  var _a;
151
167
  const isActive = activeKey === card.key;
152
168
  const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
153
169
  const hasLogos = operatorLogos.length > 0;
154
- // Three logos fill the space beside the art, so a "+N" takes the third slot
155
- const visibleLogos = operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
170
+ // Remate lists every operator; other cards show three logos, with a "+N"
171
+ // taking the third slot when there are more
172
+ const visibleLogos = !isRemateCard(card) &&
173
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
156
174
  ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
157
175
  : operatorLogos;
158
176
  const isDimmed = activeKey != null && !isActive;
@@ -180,19 +198,23 @@ class DiscountBannersDesktop extends React.Component {
180
198
  showFlamePercent && (React.createElement("span", { className: `bold-text absolute text-[#ffffff] text-center pointer-events-none ${sizeClasses.flameBadge}`, style: Object.assign(Object.assign(Object.assign({}, FLAME_CENTER), { transform: "translate(-50%, -50%)", textShadow: "0 1px 2px rgba(0,0,0,0.28)", fontWeight: 900, whiteSpace: "nowrap" }), (hasLogoRow ? logoLayout.flameBadge : undefined)) },
181
199
  card.amount,
182
200
  React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
183
- React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
201
+ React.createElement("span", { className: `flex flex-col ${sizeClasses.column}`, style: hasLogos ? logoLayout.column : undefined },
184
202
  React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.campaignTexts
185
203
  ? card.campaignTexts.text2
186
204
  : card.discountLabel),
187
205
  React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.campaignTexts
188
206
  ? card.campaignTexts.text3
189
207
  : card.subtitle)),
190
- hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]", style: logoLayout.row },
208
+ hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]",
209
+ // Clip sideways only, so the Remate logo shadow isn't cut off above and below
210
+ style: Object.assign(Object.assign(Object.assign({}, logoLayout.row), (isRemateCard(card) ? undefined : logoLayout.spacedRow)), { overflowX: "clip" }) },
191
211
  visibleLogos.map((logo) => {
192
212
  var _a;
193
- return (React.createElement("img", { key: logo.url, src: logo.url, alt: (_a = logo.name) !== null && _a !== void 0 ? _a : "", className: "block object-contain object-left", style: logoLayout.logo }));
213
+ return (React.createElement("img", { key: logo.url, src: logo.url, alt: (_a = logo.name) !== null && _a !== void 0 ? _a : "", className: "block object-contain object-left", style: Object.assign(Object.assign({}, logoLayout.logo), (isRemateCard(card)
214
+ ? { filter: REMATE_LOGO_SHADOW }
215
+ : undefined)) }));
194
216
  }),
195
- visibleLogos.length < operatorLogos.length && (React.createElement("span", null,
217
+ visibleLogos.length < operatorLogos.length && (React.createElement("span", { style: { flexShrink: 0 } },
196
218
  "+",
197
219
  operatorLogos.length - visibleLogos.length))))));
198
220
  })));
@@ -2,6 +2,7 @@
2
2
  import React from "react";
3
3
  import { CAMPAIGN_ART, DEFAULT_BACKGROUND_IMAGES, toImageUrl, } from "../../ui/DiscountBanners/defaultBackgrounds";
4
4
  import { deriveDiscountCards } from "../../ui/DiscountBanners";
5
+ import BannerScroller from "./BannerScroller";
5
6
  const DEFAULT_LABEL = "Oferta especial";
6
7
  const DEFAULT_ACCENT_PALETTE = [
7
8
  "#107E23",
@@ -14,7 +15,7 @@ const SIZE_CLASSES = {
14
15
  chip: "w-[81px] h-[17px] text-[9px] leading-[17px]",
15
16
  column: "max-w-[116px] mt-[3px]",
16
17
  discount: "text-[15px] leading-[17px]",
17
- subtitle: "text-[12px] leading-[14px] mt-[1px]",
18
+ subtitle: "text-[12px] leading-[14px] mt-[1px] max-h-[28px] overflow-hidden",
18
19
  flameBadge: "text-[18px] leading-[16px]",
19
20
  flamePercent: "text-[10px]",
20
21
  },
@@ -23,7 +24,7 @@ const SIZE_CLASSES = {
23
24
  chip: "w-[69px] h-[15px] text-[8px] leading-[15px]",
24
25
  column: "max-w-[99px] mt-[2px]",
25
26
  discount: "text-[13px] leading-[15px]",
26
- subtitle: "text-[10.5px] leading-[12px] mt-[1px]",
27
+ subtitle: "text-[10.5px] leading-[12px] mt-[1px] max-h-[24px] overflow-hidden",
27
28
  flameBadge: "text-[13.5px] leading-[13.5px]",
28
29
  flamePercent: "text-[8px]",
29
30
  },
@@ -42,14 +43,21 @@ const OPERATOR_LOGO_LAYOUT = {
42
43
  },
43
44
  flameBadge: { fontSize: "22px", lineHeight: "19px" },
44
45
  flamePercent: { fontSize: "12px" },
45
- discount: { lineHeight: "16px" },
46
+ discount: { lineHeight: "15px" },
47
+ // As wide as the logo row; long text wraps to a second line and stops there
46
48
  subtitle: {
47
49
  fontSize: "11px",
48
50
  lineHeight: "11px",
49
51
  marginTop: 0,
50
- maxWidth: "100px",
52
+ maxWidth: "128px",
53
+ maxHeight: "22px",
54
+ overflow: "hidden",
51
55
  },
52
- row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
56
+ // Tighter above the discount so a two-line subtitle still leaves the logo gap
57
+ column: { maxWidth: "128px", marginTop: 0 },
58
+ row: { gap: "3px", fontSize: "10px", marginTop: "8px", maxWidth: "128px" },
59
+ // Non-Remate cards space their logos further apart
60
+ spacedRow: { gap: "8px" },
53
61
  logo: { height: "18px", width: "84px", minWidth: 0 },
54
62
  },
55
63
  compact: {
@@ -63,19 +71,27 @@ const OPERATOR_LOGO_LAYOUT = {
63
71
  },
64
72
  flameBadge: { fontSize: "16px", lineHeight: "16px" },
65
73
  flamePercent: { fontSize: "10px" },
66
- discount: { lineHeight: "14px" },
74
+ discount: { lineHeight: "13px" },
75
+ // As wide as the logo row; long text wraps to a second line and stops there
67
76
  subtitle: {
68
77
  fontSize: "9.5px",
69
78
  lineHeight: "9.5px",
70
79
  marginTop: 0,
71
- maxWidth: "85px",
80
+ maxWidth: "108px",
81
+ maxHeight: "19px",
82
+ overflow: "hidden",
72
83
  },
73
- row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
84
+ column: { maxWidth: "108px", marginTop: 0 },
85
+ row: { gap: "2px", fontSize: "8px", marginTop: "6px", maxWidth: "108px" },
86
+ spacedRow: { gap: "6px" },
74
87
  logo: { height: "14px", width: "70px", minWidth: 0 },
75
88
  },
76
89
  };
77
90
  const MAX_VISIBLE_OPERATOR_LOGOS = 3;
78
91
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
92
+ // Lifts operator logos off the green Remate art, where light logos otherwise blend in.
93
+ // drop-shadow follows the logo's shape rather than its box.
94
+ const REMATE_LOGO_SHADOW = "drop-shadow(0 0 1px rgba(0,0,0,0.45)) drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
79
95
  const REMATE_KEY = "remate";
80
96
  function isRemateCard(card) {
81
97
  return card.isWowDeal || card.key === REMATE_KEY;
@@ -144,13 +160,15 @@ class DiscountBannersMobile extends React.Component {
144
160
  const sizeClasses = SIZE_CLASSES[size !== null && size !== void 0 ? size : "default"];
145
161
  const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
146
162
  const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
147
- return (React.createElement("div", { className: `kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`, style: style }, cards.map((card, index) => {
163
+ return (React.createElement(BannerScroller, { variant: "mobile", className: className, style: style }, cards.map((card, index) => {
148
164
  var _a;
149
165
  const isActive = activeKey === card.key;
150
166
  const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
151
167
  const hasLogos = operatorLogos.length > 0;
152
- // Three logos fill the space beside the art, so a "+N" takes the third slot
153
- const visibleLogos = operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
168
+ // Remate lists every operator; other cards show three logos, with a "+N"
169
+ // taking the third slot when there are more
170
+ const visibleLogos = !isRemateCard(card) &&
171
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
154
172
  ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
155
173
  : operatorLogos;
156
174
  const isDimmed = activeKey != null && !isActive;
@@ -178,19 +196,23 @@ class DiscountBannersMobile extends React.Component {
178
196
  showFlamePercent && (React.createElement("span", { className: `bold-text absolute text-[#ffffff] text-center pointer-events-none ${sizeClasses.flameBadge}`, style: Object.assign(Object.assign(Object.assign({}, FLAME_CENTER), { transform: "translate(-50%, -50%)", textShadow: "0 1px 2px rgba(0,0,0,0.28)", fontWeight: 900, whiteSpace: "nowrap" }), (hasLogoRow ? logoLayout.flameBadge : undefined)) },
179
197
  card.amount,
180
198
  React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
181
- React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
199
+ React.createElement("span", { className: `flex flex-col ${sizeClasses.column}`, style: hasLogos ? logoLayout.column : undefined },
182
200
  React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.campaignTexts
183
201
  ? card.campaignTexts.text2
184
202
  : card.discountLabel),
185
203
  React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.campaignTexts
186
204
  ? card.campaignTexts.text3
187
205
  : card.subtitle)),
188
- hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]", style: logoLayout.row },
206
+ hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]",
207
+ // Clip sideways only, so the Remate logo shadow isn't cut off above and below
208
+ style: Object.assign(Object.assign(Object.assign({}, logoLayout.row), (isRemateCard(card) ? undefined : logoLayout.spacedRow)), { overflowX: "clip" }) },
189
209
  visibleLogos.map((logo) => {
190
210
  var _a;
191
- return (React.createElement("img", { key: logo.url, src: logo.url, alt: (_a = logo.name) !== null && _a !== void 0 ? _a : "", className: "block object-contain object-left", style: logoLayout.logo }));
211
+ return (React.createElement("img", { key: logo.url, src: logo.url, alt: (_a = logo.name) !== null && _a !== void 0 ? _a : "", className: "block object-contain object-left", style: Object.assign(Object.assign({}, logoLayout.logo), (isRemateCard(card)
212
+ ? { filter: REMATE_LOGO_SHADOW }
213
+ : undefined)) }));
192
214
  }),
193
- visibleLogos.length < operatorLogos.length && (React.createElement("span", null,
215
+ visibleLogos.length < operatorLogos.length && (React.createElement("span", { style: { flexShrink: 0 } },
194
216
  "+",
195
217
  operatorLogos.length - visibleLogos.length))))));
196
218
  })));
package/dist/styles.css CHANGED
@@ -468,6 +468,12 @@
468
468
  .h-full {
469
469
  height: 100%;
470
470
  }
471
+ .max-h-\[24px\] {
472
+ max-height: 24px;
473
+ }
474
+ .max-h-\[28px\] {
475
+ max-height: 28px;
476
+ }
471
477
  .max-h-\[66vh\] {
472
478
  max-height: 66vh;
473
479
  }
@@ -14,7 +14,7 @@ const SIZE_CLASSES = {
14
14
  chip: "w-[81px] h-[17px] text-[9px] leading-[17px]",
15
15
  column: "max-w-[116px] mt-[3px]",
16
16
  discount: "text-[15px] leading-[17px]",
17
- subtitle: "text-[12px] leading-[14px] mt-[1px]",
17
+ subtitle: "text-[12px] leading-[14px] mt-[1px] max-h-[28px] overflow-hidden",
18
18
  flameBadge: "text-[18px] leading-[16px]",
19
19
  flamePercent: "text-[10px]",
20
20
  },
@@ -23,7 +23,7 @@ const SIZE_CLASSES = {
23
23
  chip: "w-[69px] h-[15px] text-[8px] leading-[15px]",
24
24
  column: "max-w-[99px] mt-[2px]",
25
25
  discount: "text-[13px] leading-[15px]",
26
- subtitle: "text-[10.5px] leading-[12px] mt-[1px]",
26
+ subtitle: "text-[10.5px] leading-[12px] mt-[1px] max-h-[24px] overflow-hidden",
27
27
  flameBadge: "text-[13.5px] leading-[13.5px]",
28
28
  flamePercent: "text-[8px]",
29
29
  },
@@ -42,14 +42,21 @@ const OPERATOR_LOGO_LAYOUT = {
42
42
  },
43
43
  flameBadge: { fontSize: "22px", lineHeight: "19px" },
44
44
  flamePercent: { fontSize: "12px" },
45
- discount: { lineHeight: "16px" },
45
+ discount: { lineHeight: "15px" },
46
+ // As wide as the logo row; long text wraps to a second line and stops there
46
47
  subtitle: {
47
48
  fontSize: "11px",
48
49
  lineHeight: "11px",
49
50
  marginTop: 0,
50
- maxWidth: "100px",
51
+ maxWidth: "128px",
52
+ maxHeight: "22px",
53
+ overflow: "hidden",
51
54
  },
52
- row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
55
+ // Tighter above the discount so a two-line subtitle still leaves the logo gap
56
+ column: { maxWidth: "128px", marginTop: 0 },
57
+ row: { gap: "3px", fontSize: "10px", marginTop: "8px", maxWidth: "128px" },
58
+ // Non-Remate cards space their logos further apart
59
+ spacedRow: { gap: "8px" },
53
60
  logo: { height: "18px", width: "84px", minWidth: 0 },
54
61
  },
55
62
  compact: {
@@ -63,19 +70,27 @@ const OPERATOR_LOGO_LAYOUT = {
63
70
  },
64
71
  flameBadge: { fontSize: "16px", lineHeight: "16px" },
65
72
  flamePercent: { fontSize: "10px" },
66
- discount: { lineHeight: "14px" },
73
+ discount: { lineHeight: "13px" },
74
+ // As wide as the logo row; long text wraps to a second line and stops there
67
75
  subtitle: {
68
76
  fontSize: "9.5px",
69
77
  lineHeight: "9.5px",
70
78
  marginTop: 0,
71
- maxWidth: "85px",
79
+ maxWidth: "108px",
80
+ maxHeight: "19px",
81
+ overflow: "hidden",
72
82
  },
73
- row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
83
+ column: { maxWidth: "108px", marginTop: 0 },
84
+ row: { gap: "2px", fontSize: "8px", marginTop: "6px", maxWidth: "108px" },
85
+ spacedRow: { gap: "6px" },
74
86
  logo: { height: "14px", width: "70px", minWidth: 0 },
75
87
  },
76
88
  };
77
89
  const MAX_VISIBLE_OPERATOR_LOGOS = 3;
78
90
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
91
+ // Lifts operator logos off the green Remate art, where light logos otherwise blend in.
92
+ // drop-shadow follows the logo's shape rather than its box.
93
+ const REMATE_LOGO_SHADOW = "drop-shadow(0 0 1px rgba(0,0,0,0.45)) drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
79
94
  const REMATE_KEY = "remate";
80
95
  function isRemateCard(card) {
81
96
  return card.isWowDeal || card.key === REMATE_KEY;
@@ -132,8 +147,10 @@ const DiscountBanners = ({ services = [], wowDealData, backgroundImages = [], ar
132
147
  const isActive = activeKey === card.key;
133
148
  const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
134
149
  const hasLogos = operatorLogos.length > 0;
135
- // Three logos fill the space beside the art, so a "+N" takes the third slot
136
- const visibleLogos = operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
150
+ // Remate lists every operator; other cards show three logos, with a "+N"
151
+ // taking the third slot when there are more
152
+ const visibleLogos = !isRemateCard(card) &&
153
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
137
154
  ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
138
155
  : operatorLogos;
139
156
  const isDimmed = activeKey != null && !isActive;
@@ -161,15 +178,19 @@ const DiscountBanners = ({ services = [], wowDealData, backgroundImages = [], ar
161
178
  showFlamePercent && (React.createElement("span", { className: `bold-text absolute text-[#ffffff] text-center pointer-events-none ${sizeClasses.flameBadge}`, style: Object.assign(Object.assign(Object.assign({}, FLAME_CENTER), { transform: "translate(-50%, -50%)", textShadow: "0 1px 2px rgba(0,0,0,0.28)", fontWeight: 900, whiteSpace: "nowrap" }), (hasLogoRow ? logoLayout.flameBadge : undefined)) },
162
179
  card.amount,
163
180
  React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
164
- React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
181
+ React.createElement("span", { className: `flex flex-col ${sizeClasses.column}`, style: hasLogos ? logoLayout.column : undefined },
165
182
  React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.discountLabel),
166
183
  React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.subtitle)),
167
- hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]", style: logoLayout.row },
184
+ hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]",
185
+ // Clip sideways only, so the Remate logo shadow isn't cut off above and below
186
+ style: Object.assign(Object.assign(Object.assign({}, logoLayout.row), (isRemateCard(card) ? undefined : logoLayout.spacedRow)), { overflowX: "clip" }) },
168
187
  visibleLogos.map((logo) => {
169
188
  var _a;
170
- return (React.createElement("img", { key: logo.url, src: logo.url, alt: (_a = logo.name) !== null && _a !== void 0 ? _a : "", className: "block object-contain object-left", style: logoLayout.logo }));
189
+ return (React.createElement("img", { key: logo.url, src: logo.url, alt: (_a = logo.name) !== null && _a !== void 0 ? _a : "", className: "block object-contain object-left", style: Object.assign(Object.assign({}, logoLayout.logo), (isRemateCard(card)
190
+ ? { filter: REMATE_LOGO_SHADOW }
191
+ : undefined)) }));
171
192
  }),
172
- visibleLogos.length < operatorLogos.length && (React.createElement("span", null,
193
+ visibleLogos.length < operatorLogos.length && (React.createElement("span", { style: { flexShrink: 0 } },
173
194
  "+",
174
195
  operatorLogos.length - visibleLogos.length))))));
175
196
  })));
@@ -115,7 +115,7 @@ function toDiscountLabel(discount, currencySign) {
115
115
  }
116
116
  // Groups services into one card per discount category. Remate/wow-deal is always the first card.
117
117
  export function deriveDiscountCards(services = [], wowDealData, currencySign = "$") {
118
- var _a, _b, _c, _d;
118
+ var _a, _b, _c, _d, _e;
119
119
  const cards = new Map();
120
120
  const wowServices = wowDealData === null || wowDealData === void 0 ? void 0 : wowDealData.services;
121
121
  const hasWowDeal = !!wowDealData &&
@@ -207,5 +207,15 @@ export function deriveDiscountCards(services = [], wowDealData, currencySign = "
207
207
  if (showsLogo)
208
208
  addOperatorLogo(cards.get(key), service);
209
209
  }
210
+ // Operators sharing a discount are listed alphabetically; logos without a name go last.
211
+ for (const card of cards.values()) {
212
+ (_e = card.operatorLogos) === null || _e === void 0 ? void 0 : _e.sort((a, b) => {
213
+ if (!a.name)
214
+ return b.name ? 1 : 0;
215
+ if (!b.name)
216
+ return -1;
217
+ return a.name.localeCompare(b.name, "es", { sensitivity: "base" });
218
+ });
219
+ }
210
220
  return Array.from(cards.values());
211
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kupos-ui-components-lib",
3
- "version": "10.4.69",
3
+ "version": "10.4.71",
4
4
  "description": "A reusable UI components package",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,173 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ // Each arrow click scrolls this share of the visible width, so the card cut off at the
6
+ // edge stays in view as context
7
+ const ARROW_SCROLL_FRACTION = 0.8;
8
+
9
+ const ARROW_LAYOUT = {
10
+ // Desktop arrows straddle the row's edges; mobile keeps them flush with the screen edge
11
+ desktop: { size: 28, icon: 14, offset: "-14px" },
12
+ mobile: { size: 24, icon: 12, offset: "0" },
13
+ };
14
+
15
+ function ArrowButton({
16
+ direction,
17
+ variant,
18
+ onClick,
19
+ }: {
20
+ direction: "left" | "right";
21
+ variant: "desktop" | "mobile";
22
+ onClick: () => void;
23
+ }) {
24
+ const layout = ARROW_LAYOUT[variant];
25
+ return (
26
+ <button
27
+ type="button"
28
+ aria-label={
29
+ direction === "left" ? "Ver ofertas anteriores" : "Ver más ofertas"
30
+ }
31
+ onClick={onClick}
32
+ // Inline so the placement doesn't depend on the host's stylesheet
33
+ style={{
34
+ position: "absolute",
35
+ top: "50%",
36
+ zIndex: 1,
37
+ display: "flex",
38
+ alignItems: "center",
39
+ justifyContent: "center",
40
+ padding: 0,
41
+ border: "none",
42
+ borderRadius: "50%",
43
+ background: "#fff",
44
+ cursor: "pointer",
45
+ [direction]: layout.offset,
46
+ width: layout.size,
47
+ height: layout.size,
48
+ transform: "translateY(-50%)",
49
+ boxShadow: "0 1px 4px rgba(0,0,0,0.25)",
50
+ }}
51
+ >
52
+ <svg
53
+ width={layout.icon}
54
+ height={layout.icon}
55
+ viewBox="0 0 24 24"
56
+ aria-hidden="true"
57
+ >
58
+ <path
59
+ d={direction === "left" ? "M15 5l-7 7 7 7" : "M9 5l7 7-7 7"}
60
+ fill="none"
61
+ stroke="#464647"
62
+ strokeWidth="2.5"
63
+ strokeLinecap="round"
64
+ strokeLinejoin="round"
65
+ />
66
+ </svg>
67
+ </button>
68
+ );
69
+ }
70
+
71
+ interface BannerScrollerProps {
72
+ variant: "desktop" | "mobile";
73
+ className?: string;
74
+ style?: React.CSSProperties;
75
+ children: React.ReactNode;
76
+ }
77
+
78
+ interface BannerScrollerState {
79
+ canScrollLeft: boolean;
80
+ canScrollRight: boolean;
81
+ }
82
+
83
+ // Horizontal card row with left/right arrows that appear only while cards are hidden
84
+ // in that direction
85
+ class BannerScroller extends React.Component<
86
+ BannerScrollerProps,
87
+ BannerScrollerState
88
+ > {
89
+ private scrollerRef = React.createRef<HTMLDivElement>();
90
+ private resizeObserver?: ResizeObserver;
91
+
92
+ constructor(props: BannerScrollerProps) {
93
+ super(props);
94
+ this.state = { canScrollLeft: false, canScrollRight: false };
95
+ this.updateScrollState = this.updateScrollState.bind(this);
96
+ }
97
+
98
+ componentDidMount() {
99
+ this.updateScrollState();
100
+ window.addEventListener("resize", this.updateScrollState);
101
+ if (typeof ResizeObserver !== "undefined" && this.scrollerRef.current) {
102
+ this.resizeObserver = new ResizeObserver(this.updateScrollState);
103
+ this.resizeObserver.observe(this.scrollerRef.current);
104
+ }
105
+ }
106
+
107
+ // The card set can change without the row resizing
108
+ componentDidUpdate() {
109
+ this.updateScrollState();
110
+ }
111
+
112
+ componentWillUnmount() {
113
+ window.removeEventListener("resize", this.updateScrollState);
114
+ this.resizeObserver?.disconnect();
115
+ }
116
+
117
+ // The 1px slack absorbs sub-pixel scroll positions
118
+ updateScrollState() {
119
+ const el = this.scrollerRef.current;
120
+ if (!el) return;
121
+ const canScrollLeft = el.scrollLeft > 1;
122
+ const canScrollRight = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
123
+ if (
124
+ canScrollLeft !== this.state.canScrollLeft ||
125
+ canScrollRight !== this.state.canScrollRight
126
+ ) {
127
+ this.setState({ canScrollLeft, canScrollRight });
128
+ }
129
+ }
130
+
131
+ scrollCards(direction: -1 | 1) {
132
+ const el = this.scrollerRef.current;
133
+ if (!el) return;
134
+ el.scrollBy({
135
+ left: direction * el.clientWidth * ARROW_SCROLL_FRACTION,
136
+ behavior: "smooth",
137
+ });
138
+ }
139
+
140
+ render() {
141
+ const { variant, className = "", style, children } = this.props;
142
+ const { canScrollLeft, canScrollRight } = this.state;
143
+
144
+ return (
145
+ <div style={{ position: "relative" }}>
146
+ {canScrollLeft && (
147
+ <ArrowButton
148
+ direction="left"
149
+ variant={variant}
150
+ onClick={() => this.scrollCards(-1)}
151
+ />
152
+ )}
153
+ {canScrollRight && (
154
+ <ArrowButton
155
+ direction="right"
156
+ variant={variant}
157
+ onClick={() => this.scrollCards(1)}
158
+ />
159
+ )}
160
+ <div
161
+ ref={this.scrollerRef}
162
+ onScroll={this.updateScrollState}
163
+ className={`kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`}
164
+ style={style}
165
+ >
166
+ {children}
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+ }
172
+
173
+ export default BannerScroller;
@@ -9,6 +9,7 @@ import {
9
9
  toImageUrl,
10
10
  } from "../../ui/DiscountBanners/defaultBackgrounds";
11
11
  import { deriveDiscountCards } from "../../ui/DiscountBanners";
12
+ import BannerScroller from "./BannerScroller";
12
13
 
13
14
  const DEFAULT_LABEL = "Oferta especial";
14
15
  const DEFAULT_ACCENT_PALETTE = [
@@ -23,7 +24,7 @@ const SIZE_CLASSES = {
23
24
  chip: "w-[81px] h-[17px] text-[9px] leading-[17px]",
24
25
  column: "max-w-[116px] mt-[3px]",
25
26
  discount: "text-[15px] leading-[17px]",
26
- subtitle: "text-[12px] leading-[14px] mt-[1px]",
27
+ subtitle: "text-[12px] leading-[14px] mt-[1px] max-h-[28px] overflow-hidden",
27
28
  flameBadge: "text-[18px] leading-[16px]",
28
29
  flamePercent: "text-[10px]",
29
30
  },
@@ -32,7 +33,7 @@ const SIZE_CLASSES = {
32
33
  chip: "w-[69px] h-[15px] text-[8px] leading-[15px]",
33
34
  column: "max-w-[99px] mt-[2px]",
34
35
  discount: "text-[13px] leading-[15px]",
35
- subtitle: "text-[10.5px] leading-[12px] mt-[1px]",
36
+ subtitle: "text-[10.5px] leading-[12px] mt-[1px] max-h-[24px] overflow-hidden",
36
37
  flameBadge: "text-[13.5px] leading-[13.5px]",
37
38
  flamePercent: "text-[8px]",
38
39
  },
@@ -52,14 +53,21 @@ const OPERATOR_LOGO_LAYOUT = {
52
53
  },
53
54
  flameBadge: { fontSize: "22px", lineHeight: "19px" },
54
55
  flamePercent: { fontSize: "12px" },
55
- discount: { lineHeight: "16px" },
56
+ discount: { lineHeight: "15px" },
57
+ // As wide as the logo row; long text wraps to a second line and stops there
56
58
  subtitle: {
57
59
  fontSize: "11px",
58
60
  lineHeight: "11px",
59
61
  marginTop: 0,
60
- maxWidth: "100px",
62
+ maxWidth: "128px",
63
+ maxHeight: "22px",
64
+ overflow: "hidden",
61
65
  },
62
- row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
66
+ // Tighter above the discount so a two-line subtitle still leaves the logo gap
67
+ column: { maxWidth: "128px", marginTop: 0 },
68
+ row: { gap: "3px", fontSize: "10px", marginTop: "8px", maxWidth: "128px" },
69
+ // Non-Remate cards space their logos further apart
70
+ spacedRow: { gap: "8px" },
63
71
  logo: { height: "18px", width: "84px", minWidth: 0 },
64
72
  },
65
73
  compact: {
@@ -73,14 +81,19 @@ const OPERATOR_LOGO_LAYOUT = {
73
81
  },
74
82
  flameBadge: { fontSize: "16px", lineHeight: "16px" },
75
83
  flamePercent: { fontSize: "10px" },
76
- discount: { lineHeight: "14px" },
84
+ discount: { lineHeight: "13px" },
85
+ // As wide as the logo row; long text wraps to a second line and stops there
77
86
  subtitle: {
78
87
  fontSize: "9.5px",
79
88
  lineHeight: "9.5px",
80
89
  marginTop: 0,
81
- maxWidth: "85px",
90
+ maxWidth: "108px",
91
+ maxHeight: "19px",
92
+ overflow: "hidden",
82
93
  },
83
- row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
94
+ column: { maxWidth: "108px", marginTop: 0 },
95
+ row: { gap: "2px", fontSize: "8px", marginTop: "6px", maxWidth: "108px" },
96
+ spacedRow: { gap: "6px" },
84
97
  logo: { height: "14px", width: "70px", minWidth: 0 },
85
98
  },
86
99
  };
@@ -89,6 +102,11 @@ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
89
102
 
90
103
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
91
104
 
105
+ // Lifts operator logos off the green Remate art, where light logos otherwise blend in.
106
+ // drop-shadow follows the logo's shape rather than its box.
107
+ const REMATE_LOGO_SHADOW =
108
+ "drop-shadow(0 0 1px rgba(0,0,0,0.45)) drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
109
+
92
110
  const REMATE_KEY = "remate";
93
111
 
94
112
  function isRemateCard(card: DiscountBannerCard): boolean {
@@ -199,16 +217,15 @@ class DiscountBannersDesktop extends React.Component<
199
217
  const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
200
218
 
201
219
  return (
202
- <div
203
- className={`kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`}
204
- style={style}
205
- >
220
+ <BannerScroller variant="desktop" className={className} style={style}>
206
221
  {cards.map((card, index) => {
207
222
  const isActive = activeKey === card.key;
208
223
  const operatorLogos = card.operatorLogos ?? [];
209
224
  const hasLogos = operatorLogos.length > 0;
210
- // Three logos fill the space beside the art, so a "+N" takes the third slot
225
+ // Remate lists every operator; other cards show three logos, with a "+N"
226
+ // taking the third slot when there are more
211
227
  const visibleLogos =
228
+ !isRemateCard(card) &&
212
229
  operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
213
230
  ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
214
231
  : operatorLogos;
@@ -283,7 +300,10 @@ class DiscountBannersDesktop extends React.Component<
283
300
  </span>
284
301
  </span>
285
302
  )}
286
- <span className={`flex flex-col ${sizeClasses.column}`}>
303
+ <span
304
+ className={`flex flex-col ${sizeClasses.column}`}
305
+ style={hasLogos ? logoLayout.column : undefined}
306
+ >
287
307
  <span
288
308
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
289
309
  style={hasLogos ? logoLayout.discount : undefined}
@@ -307,7 +327,12 @@ class DiscountBannersDesktop extends React.Component<
307
327
  {hasLogos && (
308
328
  <span
309
329
  className="bold-text flex items-center whitespace-nowrap text-[#464647]"
310
- style={logoLayout.row}
330
+ // Clip sideways only, so the Remate logo shadow isn't cut off above and below
331
+ style={{
332
+ ...logoLayout.row,
333
+ ...(isRemateCard(card) ? undefined : logoLayout.spacedRow),
334
+ overflowX: "clip",
335
+ }}
311
336
  >
312
337
  {visibleLogos.map((logo) => (
313
338
  <img
@@ -315,18 +340,25 @@ class DiscountBannersDesktop extends React.Component<
315
340
  src={logo.url}
316
341
  alt={logo.name ?? ""}
317
342
  className="block object-contain object-left"
318
- style={logoLayout.logo}
343
+ style={{
344
+ ...logoLayout.logo,
345
+ ...(isRemateCard(card)
346
+ ? { filter: REMATE_LOGO_SHADOW }
347
+ : undefined),
348
+ }}
319
349
  />
320
350
  ))}
321
351
  {visibleLogos.length < operatorLogos.length && (
322
- <span>+{operatorLogos.length - visibleLogos.length}</span>
352
+ <span style={{ flexShrink: 0 }}>
353
+ +{operatorLogos.length - visibleLogos.length}
354
+ </span>
323
355
  )}
324
356
  </span>
325
357
  )}
326
358
  </button>
327
359
  );
328
360
  })}
329
- </div>
361
+ </BannerScroller>
330
362
  );
331
363
  }
332
364
  }
@@ -9,6 +9,7 @@ import {
9
9
  toImageUrl,
10
10
  } from "../../ui/DiscountBanners/defaultBackgrounds";
11
11
  import { deriveDiscountCards } from "../../ui/DiscountBanners";
12
+ import BannerScroller from "./BannerScroller";
12
13
 
13
14
  const DEFAULT_LABEL = "Oferta especial";
14
15
  const DEFAULT_ACCENT_PALETTE = [
@@ -23,7 +24,7 @@ const SIZE_CLASSES = {
23
24
  chip: "w-[81px] h-[17px] text-[9px] leading-[17px]",
24
25
  column: "max-w-[116px] mt-[3px]",
25
26
  discount: "text-[15px] leading-[17px]",
26
- subtitle: "text-[12px] leading-[14px] mt-[1px]",
27
+ subtitle: "text-[12px] leading-[14px] mt-[1px] max-h-[28px] overflow-hidden",
27
28
  flameBadge: "text-[18px] leading-[16px]",
28
29
  flamePercent: "text-[10px]",
29
30
  },
@@ -32,7 +33,7 @@ const SIZE_CLASSES = {
32
33
  chip: "w-[69px] h-[15px] text-[8px] leading-[15px]",
33
34
  column: "max-w-[99px] mt-[2px]",
34
35
  discount: "text-[13px] leading-[15px]",
35
- subtitle: "text-[10.5px] leading-[12px] mt-[1px]",
36
+ subtitle: "text-[10.5px] leading-[12px] mt-[1px] max-h-[24px] overflow-hidden",
36
37
  flameBadge: "text-[13.5px] leading-[13.5px]",
37
38
  flamePercent: "text-[8px]",
38
39
  },
@@ -52,14 +53,21 @@ const OPERATOR_LOGO_LAYOUT = {
52
53
  },
53
54
  flameBadge: { fontSize: "22px", lineHeight: "19px" },
54
55
  flamePercent: { fontSize: "12px" },
55
- discount: { lineHeight: "16px" },
56
+ discount: { lineHeight: "15px" },
57
+ // As wide as the logo row; long text wraps to a second line and stops there
56
58
  subtitle: {
57
59
  fontSize: "11px",
58
60
  lineHeight: "11px",
59
61
  marginTop: 0,
60
- maxWidth: "100px",
62
+ maxWidth: "128px",
63
+ maxHeight: "22px",
64
+ overflow: "hidden",
61
65
  },
62
- row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
66
+ // Tighter above the discount so a two-line subtitle still leaves the logo gap
67
+ column: { maxWidth: "128px", marginTop: 0 },
68
+ row: { gap: "3px", fontSize: "10px", marginTop: "8px", maxWidth: "128px" },
69
+ // Non-Remate cards space their logos further apart
70
+ spacedRow: { gap: "8px" },
63
71
  logo: { height: "18px", width: "84px", minWidth: 0 },
64
72
  },
65
73
  compact: {
@@ -73,14 +81,19 @@ const OPERATOR_LOGO_LAYOUT = {
73
81
  },
74
82
  flameBadge: { fontSize: "16px", lineHeight: "16px" },
75
83
  flamePercent: { fontSize: "10px" },
76
- discount: { lineHeight: "14px" },
84
+ discount: { lineHeight: "13px" },
85
+ // As wide as the logo row; long text wraps to a second line and stops there
77
86
  subtitle: {
78
87
  fontSize: "9.5px",
79
88
  lineHeight: "9.5px",
80
89
  marginTop: 0,
81
- maxWidth: "85px",
90
+ maxWidth: "108px",
91
+ maxHeight: "19px",
92
+ overflow: "hidden",
82
93
  },
83
- row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
94
+ column: { maxWidth: "108px", marginTop: 0 },
95
+ row: { gap: "2px", fontSize: "8px", marginTop: "6px", maxWidth: "108px" },
96
+ spacedRow: { gap: "6px" },
84
97
  logo: { height: "14px", width: "70px", minWidth: 0 },
85
98
  },
86
99
  };
@@ -89,6 +102,11 @@ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
89
102
 
90
103
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
91
104
 
105
+ // Lifts operator logos off the green Remate art, where light logos otherwise blend in.
106
+ // drop-shadow follows the logo's shape rather than its box.
107
+ const REMATE_LOGO_SHADOW =
108
+ "drop-shadow(0 0 1px rgba(0,0,0,0.45)) drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
109
+
92
110
  const REMATE_KEY = "remate";
93
111
 
94
112
  function isRemateCard(card: DiscountBannerCard): boolean {
@@ -192,16 +210,15 @@ class DiscountBannersMobile extends React.Component<
192
210
  const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
193
211
 
194
212
  return (
195
- <div
196
- className={`kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`}
197
- style={style}
198
- >
213
+ <BannerScroller variant="mobile" className={className} style={style}>
199
214
  {cards.map((card, index) => {
200
215
  const isActive = activeKey === card.key;
201
216
  const operatorLogos = card.operatorLogos ?? [];
202
217
  const hasLogos = operatorLogos.length > 0;
203
- // Three logos fill the space beside the art, so a "+N" takes the third slot
218
+ // Remate lists every operator; other cards show three logos, with a "+N"
219
+ // taking the third slot when there are more
204
220
  const visibleLogos =
221
+ !isRemateCard(card) &&
205
222
  operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
206
223
  ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
207
224
  : operatorLogos;
@@ -276,7 +293,10 @@ class DiscountBannersMobile extends React.Component<
276
293
  </span>
277
294
  </span>
278
295
  )}
279
- <span className={`flex flex-col ${sizeClasses.column}`}>
296
+ <span
297
+ className={`flex flex-col ${sizeClasses.column}`}
298
+ style={hasLogos ? logoLayout.column : undefined}
299
+ >
280
300
  <span
281
301
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
282
302
  style={hasLogos ? logoLayout.discount : undefined}
@@ -300,7 +320,12 @@ class DiscountBannersMobile extends React.Component<
300
320
  {hasLogos && (
301
321
  <span
302
322
  className="bold-text flex items-center whitespace-nowrap text-[#464647]"
303
- style={logoLayout.row}
323
+ // Clip sideways only, so the Remate logo shadow isn't cut off above and below
324
+ style={{
325
+ ...logoLayout.row,
326
+ ...(isRemateCard(card) ? undefined : logoLayout.spacedRow),
327
+ overflowX: "clip",
328
+ }}
304
329
  >
305
330
  {visibleLogos.map((logo) => (
306
331
  <img
@@ -308,18 +333,25 @@ class DiscountBannersMobile extends React.Component<
308
333
  src={logo.url}
309
334
  alt={logo.name ?? ""}
310
335
  className="block object-contain object-left"
311
- style={logoLayout.logo}
336
+ style={{
337
+ ...logoLayout.logo,
338
+ ...(isRemateCard(card)
339
+ ? { filter: REMATE_LOGO_SHADOW }
340
+ : undefined),
341
+ }}
312
342
  />
313
343
  ))}
314
344
  {visibleLogos.length < operatorLogos.length && (
315
- <span>+{operatorLogos.length - visibleLogos.length}</span>
345
+ <span style={{ flexShrink: 0 }}>
346
+ +{operatorLogos.length - visibleLogos.length}
347
+ </span>
316
348
  )}
317
349
  </span>
318
350
  )}
319
351
  </button>
320
352
  );
321
353
  })}
322
- </div>
354
+ </BannerScroller>
323
355
  );
324
356
  }
325
357
  }
@@ -22,7 +22,7 @@ const SIZE_CLASSES = {
22
22
  chip: "w-[81px] h-[17px] text-[9px] leading-[17px]",
23
23
  column: "max-w-[116px] mt-[3px]",
24
24
  discount: "text-[15px] leading-[17px]",
25
- subtitle: "text-[12px] leading-[14px] mt-[1px]",
25
+ subtitle: "text-[12px] leading-[14px] mt-[1px] max-h-[28px] overflow-hidden",
26
26
  flameBadge: "text-[18px] leading-[16px]",
27
27
  flamePercent: "text-[10px]",
28
28
  },
@@ -31,7 +31,7 @@ const SIZE_CLASSES = {
31
31
  chip: "w-[69px] h-[15px] text-[8px] leading-[15px]",
32
32
  column: "max-w-[99px] mt-[2px]",
33
33
  discount: "text-[13px] leading-[15px]",
34
- subtitle: "text-[10.5px] leading-[12px] mt-[1px]",
34
+ subtitle: "text-[10.5px] leading-[12px] mt-[1px] max-h-[24px] overflow-hidden",
35
35
  flameBadge: "text-[13.5px] leading-[13.5px]",
36
36
  flamePercent: "text-[8px]",
37
37
  },
@@ -51,14 +51,21 @@ const OPERATOR_LOGO_LAYOUT = {
51
51
  },
52
52
  flameBadge: { fontSize: "22px", lineHeight: "19px" },
53
53
  flamePercent: { fontSize: "12px" },
54
- discount: { lineHeight: "16px" },
54
+ discount: { lineHeight: "15px" },
55
+ // As wide as the logo row; long text wraps to a second line and stops there
55
56
  subtitle: {
56
57
  fontSize: "11px",
57
58
  lineHeight: "11px",
58
59
  marginTop: 0,
59
- maxWidth: "100px",
60
+ maxWidth: "128px",
61
+ maxHeight: "22px",
62
+ overflow: "hidden",
60
63
  },
61
- row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
64
+ // Tighter above the discount so a two-line subtitle still leaves the logo gap
65
+ column: { maxWidth: "128px", marginTop: 0 },
66
+ row: { gap: "3px", fontSize: "10px", marginTop: "8px", maxWidth: "128px" },
67
+ // Non-Remate cards space their logos further apart
68
+ spacedRow: { gap: "8px" },
62
69
  logo: { height: "18px", width: "84px", minWidth: 0 },
63
70
  },
64
71
  compact: {
@@ -72,14 +79,19 @@ const OPERATOR_LOGO_LAYOUT = {
72
79
  },
73
80
  flameBadge: { fontSize: "16px", lineHeight: "16px" },
74
81
  flamePercent: { fontSize: "10px" },
75
- discount: { lineHeight: "14px" },
82
+ discount: { lineHeight: "13px" },
83
+ // As wide as the logo row; long text wraps to a second line and stops there
76
84
  subtitle: {
77
85
  fontSize: "9.5px",
78
86
  lineHeight: "9.5px",
79
87
  marginTop: 0,
80
- maxWidth: "85px",
88
+ maxWidth: "108px",
89
+ maxHeight: "19px",
90
+ overflow: "hidden",
81
91
  },
82
- row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
92
+ column: { maxWidth: "108px", marginTop: 0 },
93
+ row: { gap: "2px", fontSize: "8px", marginTop: "6px", maxWidth: "108px" },
94
+ spacedRow: { gap: "6px" },
83
95
  logo: { height: "14px", width: "70px", minWidth: 0 },
84
96
  },
85
97
  };
@@ -88,6 +100,11 @@ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
88
100
 
89
101
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
90
102
 
103
+ // Lifts operator logos off the green Remate art, where light logos otherwise blend in.
104
+ // drop-shadow follows the logo's shape rather than its box.
105
+ const REMATE_LOGO_SHADOW =
106
+ "drop-shadow(0 0 1px rgba(0,0,0,0.45)) drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
107
+
91
108
  const REMATE_KEY = "remate";
92
109
 
93
110
  function isRemateCard(card: DiscountBannerCard): boolean {
@@ -182,8 +199,10 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
182
199
  const isActive = activeKey === card.key;
183
200
  const operatorLogos = card.operatorLogos ?? [];
184
201
  const hasLogos = operatorLogos.length > 0;
185
- // Three logos fill the space beside the art, so a "+N" takes the third slot
202
+ // Remate lists every operator; other cards show three logos, with a "+N"
203
+ // taking the third slot when there are more
186
204
  const visibleLogos =
205
+ !isRemateCard(card) &&
187
206
  operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
188
207
  ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
189
208
  : operatorLogos;
@@ -258,7 +277,10 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
258
277
  </span>
259
278
  </span>
260
279
  )}
261
- <span className={`flex flex-col ${sizeClasses.column}`}>
280
+ <span
281
+ className={`flex flex-col ${sizeClasses.column}`}
282
+ style={hasLogos ? logoLayout.column : undefined}
283
+ >
262
284
  <span
263
285
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
264
286
  style={hasLogos ? logoLayout.discount : undefined}
@@ -278,7 +300,12 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
278
300
  {hasLogos && (
279
301
  <span
280
302
  className="bold-text flex items-center whitespace-nowrap text-[#464647]"
281
- style={logoLayout.row}
303
+ // Clip sideways only, so the Remate logo shadow isn't cut off above and below
304
+ style={{
305
+ ...logoLayout.row,
306
+ ...(isRemateCard(card) ? undefined : logoLayout.spacedRow),
307
+ overflowX: "clip",
308
+ }}
282
309
  >
283
310
  {visibleLogos.map((logo) => (
284
311
  <img
@@ -286,11 +313,18 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
286
313
  src={logo.url}
287
314
  alt={logo.name ?? ""}
288
315
  className="block object-contain object-left"
289
- style={logoLayout.logo}
316
+ style={{
317
+ ...logoLayout.logo,
318
+ ...(isRemateCard(card)
319
+ ? { filter: REMATE_LOGO_SHADOW }
320
+ : undefined),
321
+ }}
290
322
  />
291
323
  ))}
292
324
  {visibleLogos.length < operatorLogos.length && (
293
- <span>+{operatorLogos.length - visibleLogos.length}</span>
325
+ <span style={{ flexShrink: 0 }}>
326
+ +{operatorLogos.length - visibleLogos.length}
327
+ </span>
294
328
  )}
295
329
  </span>
296
330
  )}
@@ -267,5 +267,14 @@ export function deriveDiscountCards(
267
267
  if (showsLogo) addOperatorLogo(cards.get(key)!, service);
268
268
  }
269
269
 
270
+ // Operators sharing a discount are listed alphabetically; logos without a name go last.
271
+ for (const card of cards.values()) {
272
+ card.operatorLogos?.sort((a, b) => {
273
+ if (!a.name) return b.name ? 1 : 0;
274
+ if (!b.name) return -1;
275
+ return a.name.localeCompare(b.name, "es", { sensitivity: "base" });
276
+ });
277
+ }
278
+
270
279
  return Array.from(cards.values());
271
280
  }