kupos-ui-components-lib 10.4.70 → 10.4.72

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,14 +71,19 @@ 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
  };
@@ -149,7 +162,7 @@ class DiscountBannersDesktop extends React.Component {
149
162
  const sizeClasses = SIZE_CLASSES[size !== null && size !== void 0 ? size : "default"];
150
163
  const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
151
164
  const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
152
- 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) => {
153
166
  var _a;
154
167
  const isActive = activeKey === card.key;
155
168
  const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
@@ -185,7 +198,7 @@ class DiscountBannersDesktop extends React.Component {
185
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)) },
186
199
  card.amount,
187
200
  React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
188
- React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
201
+ React.createElement("span", { className: `flex flex-col ${sizeClasses.column}`, style: hasLogos ? logoLayout.column : undefined },
189
202
  React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.campaignTexts
190
203
  ? card.campaignTexts.text2
191
204
  : card.discountLabel),
@@ -194,7 +207,7 @@ class DiscountBannersDesktop extends React.Component {
194
207
  : card.subtitle)),
195
208
  hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]",
196
209
  // Clip sideways only, so the Remate logo shadow isn't cut off above and below
197
- style: Object.assign(Object.assign({}, logoLayout.row), { overflowX: "clip" }) },
210
+ style: Object.assign(Object.assign(Object.assign({}, logoLayout.row), (isRemateCard(card) ? undefined : logoLayout.spacedRow)), { overflowX: "clip" }) },
198
211
  visibleLogos.map((logo) => {
199
212
  var _a;
200
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)
@@ -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,14 +71,19 @@ 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
  };
@@ -147,7 +160,7 @@ class DiscountBannersMobile extends React.Component {
147
160
  const sizeClasses = SIZE_CLASSES[size !== null && size !== void 0 ? size : "default"];
148
161
  const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
149
162
  const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
150
- 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) => {
151
164
  var _a;
152
165
  const isActive = activeKey === card.key;
153
166
  const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
@@ -183,7 +196,7 @@ class DiscountBannersMobile extends React.Component {
183
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)) },
184
197
  card.amount,
185
198
  React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
186
- React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
199
+ React.createElement("span", { className: `flex flex-col ${sizeClasses.column}`, style: hasLogos ? logoLayout.column : undefined },
187
200
  React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.campaignTexts
188
201
  ? card.campaignTexts.text2
189
202
  : card.discountLabel),
@@ -192,7 +205,7 @@ class DiscountBannersMobile extends React.Component {
192
205
  : card.subtitle)),
193
206
  hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]",
194
207
  // Clip sideways only, so the Remate logo shadow isn't cut off above and below
195
- style: Object.assign(Object.assign({}, logoLayout.row), { overflowX: "clip" }) },
208
+ style: Object.assign(Object.assign(Object.assign({}, logoLayout.row), (isRemateCard(card) ? undefined : logoLayout.spacedRow)), { overflowX: "clip" }) },
196
209
  visibleLogos.map((logo) => {
197
210
  var _a;
198
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)
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,14 +70,19 @@ 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
  };
@@ -166,12 +178,12 @@ const DiscountBanners = ({ services = [], wowDealData, backgroundImages = [], ar
166
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)) },
167
179
  card.amount,
168
180
  React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
169
- React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
181
+ React.createElement("span", { className: `flex flex-col ${sizeClasses.column}`, style: hasLogos ? logoLayout.column : undefined },
170
182
  React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.discountLabel),
171
183
  React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.subtitle)),
172
184
  hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]",
173
185
  // Clip sideways only, so the Remate logo shadow isn't cut off above and below
174
- style: Object.assign(Object.assign({}, logoLayout.row), { overflowX: "clip" }) },
186
+ style: Object.assign(Object.assign(Object.assign({}, logoLayout.row), (isRemateCard(card) ? undefined : logoLayout.spacedRow)), { overflowX: "clip" }) },
175
187
  visibleLogos.map((logo) => {
176
188
  var _a;
177
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)
@@ -113,7 +113,7 @@ function toDiscountLabel(discount, currencySign) {
113
113
  }
114
114
  return `${Math.round(discount.amount)}% de dcto`;
115
115
  }
116
- // Groups services into one card per discount category. Remate/wow-deal is always the first card.
116
+ // Groups services into one card per discount category, ordered from highest to lowest discount.
117
117
  export function deriveDiscountCards(services = [], wowDealData, currencySign = "$") {
118
118
  var _a, _b, _c, _d, _e;
119
119
  const cards = new Map();
@@ -217,5 +217,12 @@ export function deriveDiscountCards(services = [], wowDealData, currencySign = "
217
217
  return a.name.localeCompare(b.name, "es", { sensitivity: "base" });
218
218
  });
219
219
  }
220
- return Array.from(cards.values());
220
+ // Highest discount first. Percentages and fixed amounts can't be compared, so percentages
221
+ // lead. The sort is stable, so on a tie Remate stays ahead and the rest keep service order.
222
+ return Array.from(cards.values()).sort((a, b) => {
223
+ if (a.discountType !== b.discountType) {
224
+ return a.discountType === "percentage" ? -1 : 1;
225
+ }
226
+ return b.amount - a.amount;
227
+ });
221
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kupos-ui-components-lib",
3
- "version": "10.4.70",
3
+ "version": "10.4.72",
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
  };
@@ -204,10 +217,7 @@ class DiscountBannersDesktop extends React.Component<
204
217
  const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
205
218
 
206
219
  return (
207
- <div
208
- className={`kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`}
209
- style={style}
210
- >
220
+ <BannerScroller variant="desktop" className={className} style={style}>
211
221
  {cards.map((card, index) => {
212
222
  const isActive = activeKey === card.key;
213
223
  const operatorLogos = card.operatorLogos ?? [];
@@ -290,7 +300,10 @@ class DiscountBannersDesktop extends React.Component<
290
300
  </span>
291
301
  </span>
292
302
  )}
293
- <span className={`flex flex-col ${sizeClasses.column}`}>
303
+ <span
304
+ className={`flex flex-col ${sizeClasses.column}`}
305
+ style={hasLogos ? logoLayout.column : undefined}
306
+ >
294
307
  <span
295
308
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
296
309
  style={hasLogos ? logoLayout.discount : undefined}
@@ -315,7 +328,11 @@ class DiscountBannersDesktop extends React.Component<
315
328
  <span
316
329
  className="bold-text flex items-center whitespace-nowrap text-[#464647]"
317
330
  // Clip sideways only, so the Remate logo shadow isn't cut off above and below
318
- style={{ ...logoLayout.row, overflowX: "clip" }}
331
+ style={{
332
+ ...logoLayout.row,
333
+ ...(isRemateCard(card) ? undefined : logoLayout.spacedRow),
334
+ overflowX: "clip",
335
+ }}
319
336
  >
320
337
  {visibleLogos.map((logo) => (
321
338
  <img
@@ -341,7 +358,7 @@ class DiscountBannersDesktop extends React.Component<
341
358
  </button>
342
359
  );
343
360
  })}
344
- </div>
361
+ </BannerScroller>
345
362
  );
346
363
  }
347
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
  };
@@ -197,10 +210,7 @@ class DiscountBannersMobile extends React.Component<
197
210
  const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
198
211
 
199
212
  return (
200
- <div
201
- className={`kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`}
202
- style={style}
203
- >
213
+ <BannerScroller variant="mobile" className={className} style={style}>
204
214
  {cards.map((card, index) => {
205
215
  const isActive = activeKey === card.key;
206
216
  const operatorLogos = card.operatorLogos ?? [];
@@ -283,7 +293,10 @@ class DiscountBannersMobile extends React.Component<
283
293
  </span>
284
294
  </span>
285
295
  )}
286
- <span className={`flex flex-col ${sizeClasses.column}`}>
296
+ <span
297
+ className={`flex flex-col ${sizeClasses.column}`}
298
+ style={hasLogos ? logoLayout.column : undefined}
299
+ >
287
300
  <span
288
301
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
289
302
  style={hasLogos ? logoLayout.discount : undefined}
@@ -308,7 +321,11 @@ class DiscountBannersMobile extends React.Component<
308
321
  <span
309
322
  className="bold-text flex items-center whitespace-nowrap text-[#464647]"
310
323
  // Clip sideways only, so the Remate logo shadow isn't cut off above and below
311
- style={{ ...logoLayout.row, overflowX: "clip" }}
324
+ style={{
325
+ ...logoLayout.row,
326
+ ...(isRemateCard(card) ? undefined : logoLayout.spacedRow),
327
+ overflowX: "clip",
328
+ }}
312
329
  >
313
330
  {visibleLogos.map((logo) => (
314
331
  <img
@@ -334,7 +351,7 @@ class DiscountBannersMobile extends React.Component<
334
351
  </button>
335
352
  );
336
353
  })}
337
- </div>
354
+ </BannerScroller>
338
355
  );
339
356
  }
340
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
  };
@@ -265,7 +277,10 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
265
277
  </span>
266
278
  </span>
267
279
  )}
268
- <span className={`flex flex-col ${sizeClasses.column}`}>
280
+ <span
281
+ className={`flex flex-col ${sizeClasses.column}`}
282
+ style={hasLogos ? logoLayout.column : undefined}
283
+ >
269
284
  <span
270
285
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
271
286
  style={hasLogos ? logoLayout.discount : undefined}
@@ -286,7 +301,11 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
286
301
  <span
287
302
  className="bold-text flex items-center whitespace-nowrap text-[#464647]"
288
303
  // Clip sideways only, so the Remate logo shadow isn't cut off above and below
289
- style={{ ...logoLayout.row, overflowX: "clip" }}
304
+ style={{
305
+ ...logoLayout.row,
306
+ ...(isRemateCard(card) ? undefined : logoLayout.spacedRow),
307
+ overflowX: "clip",
308
+ }}
290
309
  >
291
310
  {visibleLogos.map((logo) => (
292
311
  <img
@@ -158,7 +158,7 @@ function toDiscountLabel(
158
158
  return `${Math.round(discount.amount)}% de dcto`;
159
159
  }
160
160
 
161
- // Groups services into one card per discount category. Remate/wow-deal is always the first card.
161
+ // Groups services into one card per discount category, ordered from highest to lowest discount.
162
162
  export function deriveDiscountCards(
163
163
  services: DiscountBannerServiceItem[] = [],
164
164
  wowDealData?: WowDealData,
@@ -276,5 +276,12 @@ export function deriveDiscountCards(
276
276
  });
277
277
  }
278
278
 
279
- return Array.from(cards.values());
279
+ // Highest discount first. Percentages and fixed amounts can't be compared, so percentages
280
+ // lead. The sort is stable, so on a tie Remate stays ahead and the rest keep service order.
281
+ return Array.from(cards.values()).sort((a, b) => {
282
+ if (a.discountType !== b.discountType) {
283
+ return a.discountType === "percentage" ? -1 : 1;
284
+ }
285
+ return b.amount - a.amount;
286
+ });
280
287
  }