kupos-ui-components-lib 10.4.68 → 10.4.70

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.
@@ -28,7 +28,57 @@ const SIZE_CLASSES = {
28
28
  flamePercent: "text-[8px]",
29
29
  },
30
30
  };
31
+ // Rows with logos scale every card up 20%, keeping the art's 5:2 ratio so the pill baked
32
+ // into it doesn't distort. Inline so the sizes don't depend on the host's stylesheet.
33
+ const OPERATOR_LOGO_LAYOUT = {
34
+ default: {
35
+ card: { width: "240px", height: "96px", paddingTop: "10px" },
36
+ chip: {
37
+ width: "97px",
38
+ height: "20px",
39
+ marginLeft: "5px",
40
+ fontSize: "10px",
41
+ lineHeight: "20px",
42
+ },
43
+ flameBadge: { fontSize: "22px", lineHeight: "19px" },
44
+ flamePercent: { fontSize: "12px" },
45
+ discount: { lineHeight: "16px" },
46
+ subtitle: {
47
+ fontSize: "11px",
48
+ lineHeight: "11px",
49
+ marginTop: 0,
50
+ maxWidth: "100px",
51
+ },
52
+ row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
53
+ logo: { height: "18px", width: "84px", minWidth: 0 },
54
+ },
55
+ compact: {
56
+ card: { width: "204px", height: "82px", paddingTop: "9px" },
57
+ chip: {
58
+ width: "83px",
59
+ height: "18px",
60
+ marginLeft: "5px",
61
+ fontSize: "9px",
62
+ lineHeight: "18px",
63
+ },
64
+ flameBadge: { fontSize: "16px", lineHeight: "16px" },
65
+ flamePercent: { fontSize: "10px" },
66
+ discount: { lineHeight: "14px" },
67
+ subtitle: {
68
+ fontSize: "9.5px",
69
+ lineHeight: "9.5px",
70
+ marginTop: 0,
71
+ maxWidth: "85px",
72
+ },
73
+ row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
74
+ logo: { height: "14px", width: "70px", minWidth: 0 },
75
+ },
76
+ };
77
+ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
31
78
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
79
+ // Lifts operator logos off the green Remate art, where light logos otherwise blend in.
80
+ // drop-shadow follows the logo's shape rather than its box.
81
+ 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))";
32
82
  const REMATE_KEY = "remate";
33
83
  function isRemateCard(card) {
34
84
  return card.isWowDeal || card.key === REMATE_KEY;
@@ -84,6 +134,7 @@ class DiscountBannersDesktop extends React.Component {
84
134
  }
85
135
  render() {
86
136
  const { services = [], wowDealData, backgroundImages = [], artIncludesChipPill, accentColors = [], label = DEFAULT_LABEL, currencySign = "$", size = "default", selectedKey, className = "", style, } = this.props;
137
+ console.log("🚀 ~ DiscountBannersDesktop ~ render ~ services:", services);
87
138
  // Compute cards directly — no useMemo
88
139
  const cards = deriveDiscountCards(services, wowDealData, currencySign);
89
140
  if (!cards.length)
@@ -96,8 +147,19 @@ class DiscountBannersDesktop extends React.Component {
96
147
  const defaultArt = resolveArt(cards);
97
148
  const chipPillInArt = artIncludesChipPill !== null && artIncludesChipPill !== void 0 ? artIncludesChipPill : usingDefaultArt;
98
149
  const sizeClasses = SIZE_CLASSES[size !== null && size !== void 0 ? size : "default"];
150
+ const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
151
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
99
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) => {
153
+ var _a;
100
154
  const isActive = activeKey === card.key;
155
+ const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
156
+ const hasLogos = operatorLogos.length > 0;
157
+ // Remate lists every operator; other cards show three logos, with a "+N"
158
+ // taking the third slot when there are more
159
+ const visibleLogos = !isRemateCard(card) &&
160
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
161
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
162
+ : operatorLogos;
101
163
  const isDimmed = activeKey != null && !isActive;
102
164
  const backgroundImage = card.campaignImageUrl
103
165
  ? card.campaignImageUrl
@@ -114,25 +176,34 @@ class DiscountBannersDesktop extends React.Component {
114
176
  isFlameArt &&
115
177
  card.discountType === "percentage" &&
116
178
  card.amount > 0;
117
- return (React.createElement("button", { key: card.key, type: "button", "aria-pressed": isActive, onClick: () => this.handleCardClick(card), className: `relative flex-shrink-0 flex flex-col items-start text-left border-none bg-transparent cursor-pointer bg-no-repeat transition-opacity duration-200 ease-in-out ${sizeClasses.card} ${isDimmed ? "opacity-[0.65]" : "opacity-100"}`, style: {
118
- backgroundImage: backgroundImage
179
+ return (React.createElement("button", { key: card.key, type: "button", "aria-pressed": isActive, onClick: () => this.handleCardClick(card), className: `relative flex-shrink-0 flex flex-col items-start text-left border-none bg-transparent cursor-pointer bg-no-repeat transition-opacity duration-200 ease-in-out ${sizeClasses.card} ${isDimmed ? "opacity-[0.65]" : "opacity-100"}`, style: Object.assign({ backgroundImage: backgroundImage
119
180
  ? `url(${backgroundImage})`
120
- : undefined,
121
- backgroundSize: "100% 100%",
122
- } },
123
- React.createElement("span", { className: `bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`, style: chipPillInArt || card.campaignImageUrl
181
+ : undefined, backgroundSize: "100% 100%" }, (hasLogoRow ? logoLayout.card : undefined)) },
182
+ React.createElement("span", { className: `bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`, style: Object.assign(Object.assign({}, (chipPillInArt || card.campaignImageUrl
124
183
  ? undefined
125
- : { backgroundColor: "rgba(255,255,255,0.55)" } }, card.campaignTexts ? card.campaignTexts.text1 : label),
126
- showFlamePercent && (React.createElement("span", { className: `bold-text absolute text-[#ffffff] text-center pointer-events-none ${sizeClasses.flameBadge}`, style: Object.assign(Object.assign({}, FLAME_CENTER), { transform: "translate(-50%, -50%)", textShadow: "0 1px 2px rgba(0,0,0,0.28)", fontWeight: 900, whiteSpace: "nowrap" }) },
184
+ : { backgroundColor: "rgba(255,255,255,0.55)" })), (hasLogoRow ? logoLayout.chip : undefined)) }, card.campaignTexts ? card.campaignTexts.text1 : label),
185
+ 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)) },
127
186
  card.amount,
128
- React.createElement("span", { className: sizeClasses.flamePercent, style: { fontWeight: 700 } }, "%"))),
187
+ React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
129
188
  React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
130
- React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}` }, card.campaignTexts
189
+ React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.campaignTexts
131
190
  ? card.campaignTexts.text2
132
191
  : card.discountLabel),
133
- React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: { color: accentColor } }, card.campaignTexts
192
+ React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.campaignTexts
134
193
  ? card.campaignTexts.text3
135
- : card.subtitle))));
194
+ : card.subtitle)),
195
+ hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]",
196
+ // 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" }) },
198
+ visibleLogos.map((logo) => {
199
+ var _a;
200
+ 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)
201
+ ? { filter: REMATE_LOGO_SHADOW }
202
+ : undefined)) }));
203
+ }),
204
+ visibleLogos.length < operatorLogos.length && (React.createElement("span", { style: { flexShrink: 0 } },
205
+ "+",
206
+ operatorLogos.length - visibleLogos.length))))));
136
207
  })));
137
208
  }
138
209
  }
@@ -28,7 +28,57 @@ const SIZE_CLASSES = {
28
28
  flamePercent: "text-[8px]",
29
29
  },
30
30
  };
31
+ // Rows with logos scale every card up 20%, keeping the art's 5:2 ratio so the pill baked
32
+ // into it doesn't distort. Inline so the sizes don't depend on the host's stylesheet.
33
+ const OPERATOR_LOGO_LAYOUT = {
34
+ default: {
35
+ card: { width: "240px", height: "96px", paddingTop: "10px" },
36
+ chip: {
37
+ width: "97px",
38
+ height: "20px",
39
+ marginLeft: "5px",
40
+ fontSize: "10px",
41
+ lineHeight: "20px",
42
+ },
43
+ flameBadge: { fontSize: "22px", lineHeight: "19px" },
44
+ flamePercent: { fontSize: "12px" },
45
+ discount: { lineHeight: "16px" },
46
+ subtitle: {
47
+ fontSize: "11px",
48
+ lineHeight: "11px",
49
+ marginTop: 0,
50
+ maxWidth: "100px",
51
+ },
52
+ row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
53
+ logo: { height: "18px", width: "84px", minWidth: 0 },
54
+ },
55
+ compact: {
56
+ card: { width: "204px", height: "82px", paddingTop: "9px" },
57
+ chip: {
58
+ width: "83px",
59
+ height: "18px",
60
+ marginLeft: "5px",
61
+ fontSize: "9px",
62
+ lineHeight: "18px",
63
+ },
64
+ flameBadge: { fontSize: "16px", lineHeight: "16px" },
65
+ flamePercent: { fontSize: "10px" },
66
+ discount: { lineHeight: "14px" },
67
+ subtitle: {
68
+ fontSize: "9.5px",
69
+ lineHeight: "9.5px",
70
+ marginTop: 0,
71
+ maxWidth: "85px",
72
+ },
73
+ row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
74
+ logo: { height: "14px", width: "70px", minWidth: 0 },
75
+ },
76
+ };
77
+ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
31
78
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
79
+ // Lifts operator logos off the green Remate art, where light logos otherwise blend in.
80
+ // drop-shadow follows the logo's shape rather than its box.
81
+ 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))";
32
82
  const REMATE_KEY = "remate";
33
83
  function isRemateCard(card) {
34
84
  return card.isWowDeal || card.key === REMATE_KEY;
@@ -95,8 +145,19 @@ class DiscountBannersMobile extends React.Component {
95
145
  const defaultArt = resolveArt(cards);
96
146
  const chipPillInArt = artIncludesChipPill !== null && artIncludesChipPill !== void 0 ? artIncludesChipPill : usingDefaultArt;
97
147
  const sizeClasses = SIZE_CLASSES[size !== null && size !== void 0 ? size : "default"];
148
+ const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
149
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
98
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) => {
151
+ var _a;
99
152
  const isActive = activeKey === card.key;
153
+ const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
154
+ const hasLogos = operatorLogos.length > 0;
155
+ // Remate lists every operator; other cards show three logos, with a "+N"
156
+ // taking the third slot when there are more
157
+ const visibleLogos = !isRemateCard(card) &&
158
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
159
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
160
+ : operatorLogos;
100
161
  const isDimmed = activeKey != null && !isActive;
101
162
  const backgroundImage = card.campaignImageUrl
102
163
  ? card.campaignImageUrl
@@ -113,25 +174,34 @@ class DiscountBannersMobile extends React.Component {
113
174
  isFlameArt &&
114
175
  card.discountType === "percentage" &&
115
176
  card.amount > 0;
116
- return (React.createElement("button", { key: card.key, type: "button", "aria-pressed": isActive, onClick: () => this.handleCardClick(card), className: `relative flex-shrink-0 flex flex-col items-start text-left border-none bg-transparent cursor-pointer bg-no-repeat transition-opacity duration-200 ease-in-out ${sizeClasses.card} ${isDimmed ? "opacity-[0.65]" : "opacity-100"}`, style: {
117
- backgroundImage: backgroundImage
177
+ return (React.createElement("button", { key: card.key, type: "button", "aria-pressed": isActive, onClick: () => this.handleCardClick(card), className: `relative flex-shrink-0 flex flex-col items-start text-left border-none bg-transparent cursor-pointer bg-no-repeat transition-opacity duration-200 ease-in-out ${sizeClasses.card} ${isDimmed ? "opacity-[0.65]" : "opacity-100"}`, style: Object.assign({ backgroundImage: backgroundImage
118
178
  ? `url(${backgroundImage})`
119
- : undefined,
120
- backgroundSize: "100% 100%",
121
- } },
122
- React.createElement("span", { className: `bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`, style: chipPillInArt || card.campaignImageUrl
179
+ : undefined, backgroundSize: "100% 100%" }, (hasLogoRow ? logoLayout.card : undefined)) },
180
+ React.createElement("span", { className: `bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`, style: Object.assign(Object.assign({}, (chipPillInArt || card.campaignImageUrl
123
181
  ? undefined
124
- : { backgroundColor: "rgba(255,255,255,0.55)" } }, card.campaignTexts ? card.campaignTexts.text1 : label),
125
- showFlamePercent && (React.createElement("span", { className: `bold-text absolute text-[#ffffff] text-center pointer-events-none ${sizeClasses.flameBadge}`, style: Object.assign(Object.assign({}, FLAME_CENTER), { transform: "translate(-50%, -50%)", textShadow: "0 1px 2px rgba(0,0,0,0.28)", fontWeight: 900, whiteSpace: "nowrap" }) },
182
+ : { backgroundColor: "rgba(255,255,255,0.55)" })), (hasLogoRow ? logoLayout.chip : undefined)) }, card.campaignTexts ? card.campaignTexts.text1 : label),
183
+ 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)) },
126
184
  card.amount,
127
- React.createElement("span", { className: sizeClasses.flamePercent, style: { fontWeight: 700 } }, "%"))),
185
+ React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
128
186
  React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
129
- React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}` }, card.campaignTexts
187
+ React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.campaignTexts
130
188
  ? card.campaignTexts.text2
131
189
  : card.discountLabel),
132
- React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: { color: accentColor } }, card.campaignTexts
190
+ React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.campaignTexts
133
191
  ? card.campaignTexts.text3
134
- : card.subtitle))));
192
+ : card.subtitle)),
193
+ hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]",
194
+ // 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" }) },
196
+ visibleLogos.map((logo) => {
197
+ var _a;
198
+ 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)
199
+ ? { filter: REMATE_LOGO_SHADOW }
200
+ : undefined)) }));
201
+ }),
202
+ visibleLogos.length < operatorLogos.length && (React.createElement("span", { style: { flexShrink: 0 } },
203
+ "+",
204
+ operatorLogos.length - visibleLogos.length))))));
135
205
  })));
136
206
  }
137
207
  }
@@ -12,6 +12,7 @@ export interface DiscountBannerServiceItem {
12
12
  discounts?: Array<{
13
13
  new_ui_enabled?: boolean;
14
14
  campaign_type?: string;
15
+ operator_logo?: string;
15
16
  campaign_image_url?: string;
16
17
  campaign_text?: {
17
18
  text_1?: string;
@@ -43,6 +44,10 @@ export interface DiscountBannerCard {
43
44
  text2: string;
44
45
  text3: string;
45
46
  };
47
+ operatorLogos?: Array<{
48
+ url: string;
49
+ name?: string;
50
+ }>;
46
51
  }
47
52
  export interface DiscountBannersProps {
48
53
  services?: DiscountBannerServiceItem[];
package/dist/styles.css CHANGED
@@ -1027,6 +1027,10 @@
1027
1027
  -o-object-fit: contain;
1028
1028
  object-fit: contain;
1029
1029
  }
1030
+ .object-left {
1031
+ -o-object-position: left;
1032
+ object-position: left;
1033
+ }
1030
1034
  .p-\[4px\] {
1031
1035
  padding: 4px;
1032
1036
  }
@@ -28,7 +28,57 @@ const SIZE_CLASSES = {
28
28
  flamePercent: "text-[8px]",
29
29
  },
30
30
  };
31
+ // Rows with logos scale every card up 20%, keeping the art's 5:2 ratio so the pill baked
32
+ // into it doesn't distort. Inline so the sizes don't depend on the host's stylesheet.
33
+ const OPERATOR_LOGO_LAYOUT = {
34
+ default: {
35
+ card: { width: "240px", height: "96px", paddingTop: "10px" },
36
+ chip: {
37
+ width: "97px",
38
+ height: "20px",
39
+ marginLeft: "5px",
40
+ fontSize: "10px",
41
+ lineHeight: "20px",
42
+ },
43
+ flameBadge: { fontSize: "22px", lineHeight: "19px" },
44
+ flamePercent: { fontSize: "12px" },
45
+ discount: { lineHeight: "16px" },
46
+ subtitle: {
47
+ fontSize: "11px",
48
+ lineHeight: "11px",
49
+ marginTop: 0,
50
+ maxWidth: "100px",
51
+ },
52
+ row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
53
+ logo: { height: "18px", width: "84px", minWidth: 0 },
54
+ },
55
+ compact: {
56
+ card: { width: "204px", height: "82px", paddingTop: "9px" },
57
+ chip: {
58
+ width: "83px",
59
+ height: "18px",
60
+ marginLeft: "5px",
61
+ fontSize: "9px",
62
+ lineHeight: "18px",
63
+ },
64
+ flameBadge: { fontSize: "16px", lineHeight: "16px" },
65
+ flamePercent: { fontSize: "10px" },
66
+ discount: { lineHeight: "14px" },
67
+ subtitle: {
68
+ fontSize: "9.5px",
69
+ lineHeight: "9.5px",
70
+ marginTop: 0,
71
+ maxWidth: "85px",
72
+ },
73
+ row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
74
+ logo: { height: "14px", width: "70px", minWidth: 0 },
75
+ },
76
+ };
77
+ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
31
78
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
79
+ // Lifts operator logos off the green Remate art, where light logos otherwise blend in.
80
+ // drop-shadow follows the logo's shape rather than its box.
81
+ 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))";
32
82
  const REMATE_KEY = "remate";
33
83
  function isRemateCard(card) {
34
84
  return card.isWowDeal || card.key === REMATE_KEY;
@@ -78,8 +128,19 @@ const DiscountBanners = ({ services = [], wowDealData, backgroundImages = [], ar
78
128
  const defaultArt = resolveArt(cards);
79
129
  const chipPillInArt = artIncludesChipPill !== null && artIncludesChipPill !== void 0 ? artIncludesChipPill : usingDefaultArt;
80
130
  const sizeClasses = SIZE_CLASSES[size];
131
+ const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
132
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
81
133
  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) => {
134
+ var _a;
82
135
  const isActive = activeKey === card.key;
136
+ const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
137
+ const hasLogos = operatorLogos.length > 0;
138
+ // Remate lists every operator; other cards show three logos, with a "+N"
139
+ // taking the third slot when there are more
140
+ const visibleLogos = !isRemateCard(card) &&
141
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
142
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
143
+ : operatorLogos;
83
144
  const isDimmed = activeKey != null && !isActive;
84
145
  const backgroundImage = card.campaignImageUrl
85
146
  ? card.campaignImageUrl
@@ -96,21 +157,30 @@ const DiscountBanners = ({ services = [], wowDealData, backgroundImages = [], ar
96
157
  isFlameArt &&
97
158
  card.discountType === "percentage" &&
98
159
  card.amount > 0;
99
- return (React.createElement("button", { key: card.key, type: "button", "aria-pressed": isActive, onClick: () => handleCardClick(card), className: `relative flex-shrink-0 flex flex-col items-start text-left border-none bg-transparent cursor-pointer bg-no-repeat transition-opacity duration-200 ease-in-out ${sizeClasses.card} ${isDimmed ? "opacity-[0.65]" : "opacity-100"}`, style: {
100
- backgroundImage: backgroundImage
160
+ return (React.createElement("button", { key: card.key, type: "button", "aria-pressed": isActive, onClick: () => handleCardClick(card), className: `relative flex-shrink-0 flex flex-col items-start text-left border-none bg-transparent cursor-pointer bg-no-repeat transition-opacity duration-200 ease-in-out ${sizeClasses.card} ${isDimmed ? "opacity-[0.65]" : "opacity-100"}`, style: Object.assign({ backgroundImage: backgroundImage
101
161
  ? `url(${backgroundImage})`
102
- : undefined,
103
- backgroundSize: "100% 100%",
104
- } },
105
- React.createElement("span", { className: `bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`, style: chipPillInArt || card.campaignImageUrl
162
+ : undefined, backgroundSize: "100% 100%" }, (hasLogoRow ? logoLayout.card : undefined)) },
163
+ React.createElement("span", { className: `bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`, style: Object.assign(Object.assign({}, (chipPillInArt || card.campaignImageUrl
106
164
  ? undefined
107
- : { backgroundColor: "rgba(255,255,255,0.55)" } }, card.campaignTexts ? card.campaignTexts.text1 : label),
108
- showFlamePercent && (React.createElement("span", { className: `bold-text absolute text-[#ffffff] text-center pointer-events-none ${sizeClasses.flameBadge}`, style: Object.assign(Object.assign({}, FLAME_CENTER), { transform: "translate(-50%, -50%)", textShadow: "0 1px 2px rgba(0,0,0,0.28)", fontWeight: 900, whiteSpace: "nowrap" }) },
165
+ : { backgroundColor: "rgba(255,255,255,0.55)" })), (hasLogoRow ? logoLayout.chip : undefined)) }, card.campaignTexts ? card.campaignTexts.text1 : label),
166
+ 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)) },
109
167
  card.amount,
110
- React.createElement("span", { className: sizeClasses.flamePercent, style: { fontWeight: 700 } }, "%"))),
168
+ React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
111
169
  React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
112
- React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}` }, card.discountLabel),
113
- React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: { color: accentColor } }, card.subtitle))));
170
+ React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.discountLabel),
171
+ React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.subtitle)),
172
+ hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]",
173
+ // 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" }) },
175
+ visibleLogos.map((logo) => {
176
+ var _a;
177
+ 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)
178
+ ? { filter: REMATE_LOGO_SHADOW }
179
+ : undefined)) }));
180
+ }),
181
+ visibleLogos.length < operatorLogos.length && (React.createElement("span", { style: { flexShrink: 0 } },
182
+ "+",
183
+ operatorLogos.length - visibleLogos.length))))));
114
184
  })));
115
185
  };
116
186
  export default DiscountBanners;
@@ -2,6 +2,8 @@ import CommonService from "../../utils/CommonService";
2
2
  const DESCUENTOS_ESPECIALES_KEY = "descuentos_especiales";
3
3
  const DESCUENTOS_ESPECIALES_LABEL = "Descuentos especiales";
4
4
  const REMATE_KEY = "remate";
5
+ const OPERATOR_LOGO_MIN_PERCENT = 30;
6
+ const OPERATOR_LOGO_CAMPAIGN_TYPE = "operator_logo";
5
7
  export const CAMPAIGN_TYPE_LABELS = {
6
8
  last_minute: "Oferta de último minuto",
7
9
  first_time: "Primera compra",
@@ -80,6 +82,31 @@ function getServiceDiscount(service) {
80
82
  }
81
83
  return null;
82
84
  }
85
+ // The check uses the rounded amount so it agrees with the percent printed on the card.
86
+ function meetsLogoThreshold(type, amount) {
87
+ return type === "percentage" && Math.round(amount) >= OPERATOR_LOGO_MIN_PERCENT;
88
+ }
89
+ function showsOperatorLogo(service, discount) {
90
+ var _a;
91
+ const campaign = (_a = service.discounts) === null || _a === void 0 ? void 0 : _a[0];
92
+ return (!!(campaign === null || campaign === void 0 ? void 0 : campaign.operator_logo) ||
93
+ (campaign === null || campaign === void 0 ? void 0 : campaign.campaign_type) === OPERATOR_LOGO_CAMPAIGN_TYPE ||
94
+ meetsLogoThreshold(discount.type, discount.amount));
95
+ }
96
+ // Wow-deal services send operator_logo_url/operator_name; regular services send
97
+ // operator_details. A logo sent on the campaign wins over both.
98
+ function addOperatorLogo(card, service) {
99
+ var _a, _b, _c, _d, _e, _f;
100
+ const url = ((_b = (_a = service.discounts) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.operator_logo) ||
101
+ service.operator_logo_url ||
102
+ ((_c = service.operator_details) === null || _c === void 0 ? void 0 : _c[0]);
103
+ if (!url || ((_d = card.operatorLogos) === null || _d === void 0 ? void 0 : _d.some((logo) => logo.url === url)))
104
+ return;
105
+ card.operatorLogos = [
106
+ ...((_e = card.operatorLogos) !== null && _e !== void 0 ? _e : []),
107
+ { url, name: service.operator_name || ((_f = service.operator_details) === null || _f === void 0 ? void 0 : _f[2]) },
108
+ ];
109
+ }
83
110
  function toDiscountLabel(discount, currencySign) {
84
111
  if (discount.type === "fixed") {
85
112
  return `${CommonService.currencyRounded(discount.amount, currencySign)} de dcto`;
@@ -88,7 +115,7 @@ function toDiscountLabel(discount, currencySign) {
88
115
  }
89
116
  // Groups services into one card per discount category. Remate/wow-deal is always the first card.
90
117
  export function deriveDiscountCards(services = [], wowDealData, currencySign = "$") {
91
- var _a, _b, _c, _d;
118
+ var _a, _b, _c, _d, _e;
92
119
  const cards = new Map();
93
120
  const wowServices = wowDealData === null || wowDealData === void 0 ? void 0 : wowDealData.services;
94
121
  const hasWowDeal = !!wowDealData &&
@@ -108,12 +135,18 @@ export function deriveDiscountCards(services = [], wowDealData, currencySign = "
108
135
  matchedServices: Array.isArray(wowServices) ? wowServices : [],
109
136
  isWowDeal: true,
110
137
  });
138
+ if (meetsLogoThreshold(remateDiscount.type, remateDiscount.amount)) {
139
+ for (const service of cards.get(REMATE_KEY).matchedServices) {
140
+ addOperatorLogo(cards.get(REMATE_KEY), service);
141
+ }
142
+ }
111
143
  }
112
144
  for (const service of services || []) {
113
145
  const discount = getServiceDiscount(service);
114
146
  if (!discount)
115
147
  continue;
116
148
  const roundedAmount = Math.round(discount.amount);
149
+ const showsLogo = showsOperatorLogo(service, discount);
117
150
  // Backend-driven cards: art and text come from the service; services with the
118
151
  // same discount (and same campaign content) are grouped into one card.
119
152
  const backendCampaign = discount.isDp ? null : getBackendCampaign(service);
@@ -138,15 +171,19 @@ export function deriveDiscountCards(services = [], wowDealData, currencySign = "
138
171
  campaignTexts: texts,
139
172
  });
140
173
  }
174
+ if (showsLogo)
175
+ addOperatorLogo(cards.get(key), service);
141
176
  continue;
142
177
  }
143
178
  const campaignType = (_d = (_c = service.discounts) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d.campaign_type;
144
179
  const campaignLabel = campaignType
145
180
  ? CAMPAIGN_TYPE_LABELS[campaignType]
146
181
  : undefined;
147
- const category = campaignType
182
+ // Logo cards group purely by discount, so every operator offering the same percent
183
+ // lands on one banner even when their offer_text differs.
184
+ const category = campaignType && campaignType !== OPERATOR_LOGO_CAMPAIGN_TYPE
148
185
  ? slugify(campaignType)
149
- : service.offer_text
186
+ : service.offer_text && !showsLogo
150
187
  ? slugify(service.offer_text)
151
188
  : DESCUENTOS_ESPECIALES_KEY;
152
189
  const subtitle = campaignLabel || DESCUENTOS_ESPECIALES_LABEL;
@@ -167,6 +204,18 @@ export function deriveDiscountCards(services = [], wowDealData, currencySign = "
167
204
  isWowDeal: false,
168
205
  });
169
206
  }
207
+ if (showsLogo)
208
+ addOperatorLogo(cards.get(key), service);
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
+ });
170
219
  }
171
220
  return Array.from(cards.values());
172
221
  }
@@ -12,6 +12,7 @@ export interface DiscountBannerServiceItem {
12
12
  discounts?: Array<{
13
13
  new_ui_enabled?: boolean;
14
14
  campaign_type?: string;
15
+ operator_logo?: string;
15
16
  campaign_image_url?: string;
16
17
  campaign_text?: {
17
18
  text_1?: string;
@@ -43,6 +44,10 @@ export interface DiscountBannerCard {
43
44
  text2: string;
44
45
  text3: string;
45
46
  };
47
+ operatorLogos?: Array<{
48
+ url: string;
49
+ name?: string;
50
+ }>;
46
51
  }
47
52
  export interface DiscountBannersProps {
48
53
  services?: DiscountBannerServiceItem[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kupos-ui-components-lib",
3
- "version": "10.4.68",
3
+ "version": "10.4.70",
4
4
  "description": "A reusable UI components package",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -38,8 +38,62 @@ const SIZE_CLASSES = {
38
38
  },
39
39
  };
40
40
 
41
+ // Rows with logos scale every card up 20%, keeping the art's 5:2 ratio so the pill baked
42
+ // into it doesn't distort. Inline so the sizes don't depend on the host's stylesheet.
43
+ const OPERATOR_LOGO_LAYOUT = {
44
+ default: {
45
+ card: { width: "240px", height: "96px", paddingTop: "10px" },
46
+ chip: {
47
+ width: "97px",
48
+ height: "20px",
49
+ marginLeft: "5px",
50
+ fontSize: "10px",
51
+ lineHeight: "20px",
52
+ },
53
+ flameBadge: { fontSize: "22px", lineHeight: "19px" },
54
+ flamePercent: { fontSize: "12px" },
55
+ discount: { lineHeight: "16px" },
56
+ subtitle: {
57
+ fontSize: "11px",
58
+ lineHeight: "11px",
59
+ marginTop: 0,
60
+ maxWidth: "100px",
61
+ },
62
+ row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
63
+ logo: { height: "18px", width: "84px", minWidth: 0 },
64
+ },
65
+ compact: {
66
+ card: { width: "204px", height: "82px", paddingTop: "9px" },
67
+ chip: {
68
+ width: "83px",
69
+ height: "18px",
70
+ marginLeft: "5px",
71
+ fontSize: "9px",
72
+ lineHeight: "18px",
73
+ },
74
+ flameBadge: { fontSize: "16px", lineHeight: "16px" },
75
+ flamePercent: { fontSize: "10px" },
76
+ discount: { lineHeight: "14px" },
77
+ subtitle: {
78
+ fontSize: "9.5px",
79
+ lineHeight: "9.5px",
80
+ marginTop: 0,
81
+ maxWidth: "85px",
82
+ },
83
+ row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
84
+ logo: { height: "14px", width: "70px", minWidth: 0 },
85
+ },
86
+ };
87
+
88
+ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
89
+
41
90
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
42
91
 
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 =
95
+ "drop-shadow(0 0 1px rgba(0,0,0,0.45)) drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
96
+
43
97
  const REMATE_KEY = "remate";
44
98
 
45
99
  function isRemateCard(card: DiscountBannerCard): boolean {
@@ -130,6 +184,7 @@ class DiscountBannersDesktop extends React.Component<
130
184
  className = "",
131
185
  style,
132
186
  } = this.props;
187
+ console.log("🚀 ~ DiscountBannersDesktop ~ render ~ services:", services)
133
188
 
134
189
  // Compute cards directly — no useMemo
135
190
  const cards = deriveDiscountCards(services, wowDealData, currencySign);
@@ -145,6 +200,8 @@ class DiscountBannersDesktop extends React.Component<
145
200
  const defaultArt = resolveArt(cards);
146
201
  const chipPillInArt = artIncludesChipPill ?? usingDefaultArt;
147
202
  const sizeClasses = SIZE_CLASSES[size ?? "default"];
203
+ const hasLogoRow = cards.some((card) => card.operatorLogos?.length);
204
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
148
205
 
149
206
  return (
150
207
  <div
@@ -153,6 +210,15 @@ class DiscountBannersDesktop extends React.Component<
153
210
  >
154
211
  {cards.map((card, index) => {
155
212
  const isActive = activeKey === card.key;
213
+ const operatorLogos = card.operatorLogos ?? [];
214
+ const hasLogos = operatorLogos.length > 0;
215
+ // Remate lists every operator; other cards show three logos, with a "+N"
216
+ // taking the third slot when there are more
217
+ const visibleLogos =
218
+ !isRemateCard(card) &&
219
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
220
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
221
+ : operatorLogos;
156
222
  const isDimmed = activeKey != null && !isActive;
157
223
  const backgroundImage = card.campaignImageUrl
158
224
  ? card.campaignImageUrl
@@ -186,15 +252,17 @@ class DiscountBannersDesktop extends React.Component<
186
252
  ? `url(${backgroundImage})`
187
253
  : undefined,
188
254
  backgroundSize: "100% 100%",
255
+ ...(hasLogoRow ? logoLayout.card : undefined),
189
256
  }}
190
257
  >
191
258
  <span
192
259
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
193
- style={
194
- chipPillInArt || card.campaignImageUrl
260
+ style={{
261
+ ...(chipPillInArt || card.campaignImageUrl
195
262
  ? undefined
196
- : { backgroundColor: "rgba(255,255,255,0.55)" }
197
- }
263
+ : { backgroundColor: "rgba(255,255,255,0.55)" }),
264
+ ...(hasLogoRow ? logoLayout.chip : undefined),
265
+ }}
198
266
  >
199
267
  {card.campaignTexts ? card.campaignTexts.text1 : label}
200
268
  </span>
@@ -207,12 +275,16 @@ class DiscountBannersDesktop extends React.Component<
207
275
  textShadow: "0 1px 2px rgba(0,0,0,0.28)",
208
276
  fontWeight: 900,
209
277
  whiteSpace: "nowrap",
278
+ ...(hasLogoRow ? logoLayout.flameBadge : undefined),
210
279
  }}
211
280
  >
212
281
  {card.amount}
213
282
  <span
214
283
  className={sizeClasses.flamePercent}
215
- style={{ fontWeight: 700 }}
284
+ style={{
285
+ fontWeight: 700,
286
+ ...(hasLogoRow ? logoLayout.flamePercent : undefined),
287
+ }}
216
288
  >
217
289
  %
218
290
  </span>
@@ -221,6 +293,7 @@ class DiscountBannersDesktop extends React.Component<
221
293
  <span className={`flex flex-col ${sizeClasses.column}`}>
222
294
  <span
223
295
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
296
+ style={hasLogos ? logoLayout.discount : undefined}
224
297
  >
225
298
  {card.campaignTexts
226
299
  ? card.campaignTexts.text2
@@ -228,13 +301,43 @@ class DiscountBannersDesktop extends React.Component<
228
301
  </span>
229
302
  <span
230
303
  className={`bold-text ${sizeClasses.subtitle}`}
231
- style={{ color: accentColor }}
304
+ style={{
305
+ color: accentColor,
306
+ ...(hasLogos ? logoLayout.subtitle : undefined),
307
+ }}
232
308
  >
233
309
  {card.campaignTexts
234
310
  ? card.campaignTexts.text3
235
311
  : card.subtitle}
236
312
  </span>
237
313
  </span>
314
+ {hasLogos && (
315
+ <span
316
+ className="bold-text flex items-center whitespace-nowrap text-[#464647]"
317
+ // Clip sideways only, so the Remate logo shadow isn't cut off above and below
318
+ style={{ ...logoLayout.row, overflowX: "clip" }}
319
+ >
320
+ {visibleLogos.map((logo) => (
321
+ <img
322
+ key={logo.url}
323
+ src={logo.url}
324
+ alt={logo.name ?? ""}
325
+ className="block object-contain object-left"
326
+ style={{
327
+ ...logoLayout.logo,
328
+ ...(isRemateCard(card)
329
+ ? { filter: REMATE_LOGO_SHADOW }
330
+ : undefined),
331
+ }}
332
+ />
333
+ ))}
334
+ {visibleLogos.length < operatorLogos.length && (
335
+ <span style={{ flexShrink: 0 }}>
336
+ +{operatorLogos.length - visibleLogos.length}
337
+ </span>
338
+ )}
339
+ </span>
340
+ )}
238
341
  </button>
239
342
  );
240
343
  })}
@@ -38,8 +38,62 @@ const SIZE_CLASSES = {
38
38
  },
39
39
  };
40
40
 
41
+ // Rows with logos scale every card up 20%, keeping the art's 5:2 ratio so the pill baked
42
+ // into it doesn't distort. Inline so the sizes don't depend on the host's stylesheet.
43
+ const OPERATOR_LOGO_LAYOUT = {
44
+ default: {
45
+ card: { width: "240px", height: "96px", paddingTop: "10px" },
46
+ chip: {
47
+ width: "97px",
48
+ height: "20px",
49
+ marginLeft: "5px",
50
+ fontSize: "10px",
51
+ lineHeight: "20px",
52
+ },
53
+ flameBadge: { fontSize: "22px", lineHeight: "19px" },
54
+ flamePercent: { fontSize: "12px" },
55
+ discount: { lineHeight: "16px" },
56
+ subtitle: {
57
+ fontSize: "11px",
58
+ lineHeight: "11px",
59
+ marginTop: 0,
60
+ maxWidth: "100px",
61
+ },
62
+ row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
63
+ logo: { height: "18px", width: "84px", minWidth: 0 },
64
+ },
65
+ compact: {
66
+ card: { width: "204px", height: "82px", paddingTop: "9px" },
67
+ chip: {
68
+ width: "83px",
69
+ height: "18px",
70
+ marginLeft: "5px",
71
+ fontSize: "9px",
72
+ lineHeight: "18px",
73
+ },
74
+ flameBadge: { fontSize: "16px", lineHeight: "16px" },
75
+ flamePercent: { fontSize: "10px" },
76
+ discount: { lineHeight: "14px" },
77
+ subtitle: {
78
+ fontSize: "9.5px",
79
+ lineHeight: "9.5px",
80
+ marginTop: 0,
81
+ maxWidth: "85px",
82
+ },
83
+ row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
84
+ logo: { height: "14px", width: "70px", minWidth: 0 },
85
+ },
86
+ };
87
+
88
+ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
89
+
41
90
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
42
91
 
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 =
95
+ "drop-shadow(0 0 1px rgba(0,0,0,0.45)) drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
96
+
43
97
  const REMATE_KEY = "remate";
44
98
 
45
99
  function isRemateCard(card: DiscountBannerCard): boolean {
@@ -139,6 +193,8 @@ class DiscountBannersMobile extends React.Component<
139
193
  const defaultArt = resolveArt(cards);
140
194
  const chipPillInArt = artIncludesChipPill ?? usingDefaultArt;
141
195
  const sizeClasses = SIZE_CLASSES[size ?? "default"];
196
+ const hasLogoRow = cards.some((card) => card.operatorLogos?.length);
197
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
142
198
 
143
199
  return (
144
200
  <div
@@ -147,6 +203,15 @@ class DiscountBannersMobile extends React.Component<
147
203
  >
148
204
  {cards.map((card, index) => {
149
205
  const isActive = activeKey === card.key;
206
+ const operatorLogos = card.operatorLogos ?? [];
207
+ const hasLogos = operatorLogos.length > 0;
208
+ // Remate lists every operator; other cards show three logos, with a "+N"
209
+ // taking the third slot when there are more
210
+ const visibleLogos =
211
+ !isRemateCard(card) &&
212
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
213
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
214
+ : operatorLogos;
150
215
  const isDimmed = activeKey != null && !isActive;
151
216
  const backgroundImage = card.campaignImageUrl
152
217
  ? card.campaignImageUrl
@@ -180,15 +245,17 @@ class DiscountBannersMobile extends React.Component<
180
245
  ? `url(${backgroundImage})`
181
246
  : undefined,
182
247
  backgroundSize: "100% 100%",
248
+ ...(hasLogoRow ? logoLayout.card : undefined),
183
249
  }}
184
250
  >
185
251
  <span
186
252
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
187
- style={
188
- chipPillInArt || card.campaignImageUrl
253
+ style={{
254
+ ...(chipPillInArt || card.campaignImageUrl
189
255
  ? undefined
190
- : { backgroundColor: "rgba(255,255,255,0.55)" }
191
- }
256
+ : { backgroundColor: "rgba(255,255,255,0.55)" }),
257
+ ...(hasLogoRow ? logoLayout.chip : undefined),
258
+ }}
192
259
  >
193
260
  {card.campaignTexts ? card.campaignTexts.text1 : label}
194
261
  </span>
@@ -201,12 +268,16 @@ class DiscountBannersMobile extends React.Component<
201
268
  textShadow: "0 1px 2px rgba(0,0,0,0.28)",
202
269
  fontWeight: 900,
203
270
  whiteSpace: "nowrap",
271
+ ...(hasLogoRow ? logoLayout.flameBadge : undefined),
204
272
  }}
205
273
  >
206
274
  {card.amount}
207
275
  <span
208
276
  className={sizeClasses.flamePercent}
209
- style={{ fontWeight: 700 }}
277
+ style={{
278
+ fontWeight: 700,
279
+ ...(hasLogoRow ? logoLayout.flamePercent : undefined),
280
+ }}
210
281
  >
211
282
  %
212
283
  </span>
@@ -215,6 +286,7 @@ class DiscountBannersMobile extends React.Component<
215
286
  <span className={`flex flex-col ${sizeClasses.column}`}>
216
287
  <span
217
288
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
289
+ style={hasLogos ? logoLayout.discount : undefined}
218
290
  >
219
291
  {card.campaignTexts
220
292
  ? card.campaignTexts.text2
@@ -222,13 +294,43 @@ class DiscountBannersMobile extends React.Component<
222
294
  </span>
223
295
  <span
224
296
  className={`bold-text ${sizeClasses.subtitle}`}
225
- style={{ color: accentColor }}
297
+ style={{
298
+ color: accentColor,
299
+ ...(hasLogos ? logoLayout.subtitle : undefined),
300
+ }}
226
301
  >
227
302
  {card.campaignTexts
228
303
  ? card.campaignTexts.text3
229
304
  : card.subtitle}
230
305
  </span>
231
306
  </span>
307
+ {hasLogos && (
308
+ <span
309
+ className="bold-text flex items-center whitespace-nowrap text-[#464647]"
310
+ // Clip sideways only, so the Remate logo shadow isn't cut off above and below
311
+ style={{ ...logoLayout.row, overflowX: "clip" }}
312
+ >
313
+ {visibleLogos.map((logo) => (
314
+ <img
315
+ key={logo.url}
316
+ src={logo.url}
317
+ alt={logo.name ?? ""}
318
+ className="block object-contain object-left"
319
+ style={{
320
+ ...logoLayout.logo,
321
+ ...(isRemateCard(card)
322
+ ? { filter: REMATE_LOGO_SHADOW }
323
+ : undefined),
324
+ }}
325
+ />
326
+ ))}
327
+ {visibleLogos.length < operatorLogos.length && (
328
+ <span style={{ flexShrink: 0 }}>
329
+ +{operatorLogos.length - visibleLogos.length}
330
+ </span>
331
+ )}
332
+ </span>
333
+ )}
232
334
  </button>
233
335
  );
234
336
  })}
@@ -13,6 +13,7 @@ export interface DiscountBannerServiceItem {
13
13
  discounts?: Array<{
14
14
  new_ui_enabled?: boolean;
15
15
  campaign_type?: string;
16
+ operator_logo?: string;
16
17
  campaign_image_url?: string;
17
18
  campaign_text?: { text_1?: string; text_2?: string; text_3?: string };
18
19
  }>;
@@ -39,6 +40,9 @@ export interface DiscountBannerCard {
39
40
  // Present when the card's art and copy come from the backend
40
41
  campaignImageUrl?: string;
41
42
  campaignTexts?: { text1: string; text2: string; text3: string };
43
+ // One entry per operator on the card; set on percentage cards of 30% or more, or when
44
+ // the campaign asks for a logo
45
+ operatorLogos?: Array<{ url: string; name?: string }>;
42
46
  }
43
47
 
44
48
  export interface DiscountBannersProps {
@@ -37,8 +37,62 @@ const SIZE_CLASSES = {
37
37
  },
38
38
  };
39
39
 
40
+ // Rows with logos scale every card up 20%, keeping the art's 5:2 ratio so the pill baked
41
+ // into it doesn't distort. Inline so the sizes don't depend on the host's stylesheet.
42
+ const OPERATOR_LOGO_LAYOUT = {
43
+ default: {
44
+ card: { width: "240px", height: "96px", paddingTop: "10px" },
45
+ chip: {
46
+ width: "97px",
47
+ height: "20px",
48
+ marginLeft: "5px",
49
+ fontSize: "10px",
50
+ lineHeight: "20px",
51
+ },
52
+ flameBadge: { fontSize: "22px", lineHeight: "19px" },
53
+ flamePercent: { fontSize: "12px" },
54
+ discount: { lineHeight: "16px" },
55
+ subtitle: {
56
+ fontSize: "11px",
57
+ lineHeight: "11px",
58
+ marginTop: 0,
59
+ maxWidth: "100px",
60
+ },
61
+ row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
62
+ logo: { height: "18px", width: "84px", minWidth: 0 },
63
+ },
64
+ compact: {
65
+ card: { width: "204px", height: "82px", paddingTop: "9px" },
66
+ chip: {
67
+ width: "83px",
68
+ height: "18px",
69
+ marginLeft: "5px",
70
+ fontSize: "9px",
71
+ lineHeight: "18px",
72
+ },
73
+ flameBadge: { fontSize: "16px", lineHeight: "16px" },
74
+ flamePercent: { fontSize: "10px" },
75
+ discount: { lineHeight: "14px" },
76
+ subtitle: {
77
+ fontSize: "9.5px",
78
+ lineHeight: "9.5px",
79
+ marginTop: 0,
80
+ maxWidth: "85px",
81
+ },
82
+ row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
83
+ logo: { height: "14px", width: "70px", minWidth: 0 },
84
+ },
85
+ };
86
+
87
+ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
88
+
40
89
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
41
90
 
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 =
94
+ "drop-shadow(0 0 1px rgba(0,0,0,0.45)) drop-shadow(0 1px 2px rgba(0,0,0,0.3))";
95
+
42
96
  const REMATE_KEY = "remate";
43
97
 
44
98
  function isRemateCard(card: DiscountBannerCard): boolean {
@@ -121,6 +175,8 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
121
175
  const defaultArt = resolveArt(cards);
122
176
  const chipPillInArt = artIncludesChipPill ?? usingDefaultArt;
123
177
  const sizeClasses = SIZE_CLASSES[size];
178
+ const hasLogoRow = cards.some((card) => card.operatorLogos?.length);
179
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
124
180
 
125
181
  return (
126
182
  <div
@@ -129,6 +185,15 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
129
185
  >
130
186
  {cards.map((card, index) => {
131
187
  const isActive = activeKey === card.key;
188
+ const operatorLogos = card.operatorLogos ?? [];
189
+ const hasLogos = operatorLogos.length > 0;
190
+ // Remate lists every operator; other cards show three logos, with a "+N"
191
+ // taking the third slot when there are more
192
+ const visibleLogos =
193
+ !isRemateCard(card) &&
194
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
195
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
196
+ : operatorLogos;
132
197
  const isDimmed = activeKey != null && !isActive;
133
198
  const backgroundImage = card.campaignImageUrl
134
199
  ? card.campaignImageUrl
@@ -162,15 +227,17 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
162
227
  ? `url(${backgroundImage})`
163
228
  : undefined,
164
229
  backgroundSize: "100% 100%",
230
+ ...(hasLogoRow ? logoLayout.card : undefined),
165
231
  }}
166
232
  >
167
233
  <span
168
234
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
169
- style={
170
- chipPillInArt || card.campaignImageUrl
235
+ style={{
236
+ ...(chipPillInArt || card.campaignImageUrl
171
237
  ? undefined
172
- : { backgroundColor: "rgba(255,255,255,0.55)" }
173
- }
238
+ : { backgroundColor: "rgba(255,255,255,0.55)" }),
239
+ ...(hasLogoRow ? logoLayout.chip : undefined),
240
+ }}
174
241
  >
175
242
  {card.campaignTexts ? card.campaignTexts.text1 : label}
176
243
  </span>
@@ -183,12 +250,16 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
183
250
  textShadow: "0 1px 2px rgba(0,0,0,0.28)",
184
251
  fontWeight: 900,
185
252
  whiteSpace: "nowrap",
253
+ ...(hasLogoRow ? logoLayout.flameBadge : undefined),
186
254
  }}
187
255
  >
188
256
  {card.amount}
189
257
  <span
190
258
  className={sizeClasses.flamePercent}
191
- style={{ fontWeight: 700 }}
259
+ style={{
260
+ fontWeight: 700,
261
+ ...(hasLogoRow ? logoLayout.flamePercent : undefined),
262
+ }}
192
263
  >
193
264
  %
194
265
  </span>
@@ -197,16 +268,47 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
197
268
  <span className={`flex flex-col ${sizeClasses.column}`}>
198
269
  <span
199
270
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
271
+ style={hasLogos ? logoLayout.discount : undefined}
200
272
  >
201
273
  {card.discountLabel}
202
274
  </span>
203
275
  <span
204
276
  className={`bold-text ${sizeClasses.subtitle}`}
205
- style={{ color: accentColor }}
277
+ style={{
278
+ color: accentColor,
279
+ ...(hasLogos ? logoLayout.subtitle : undefined),
280
+ }}
206
281
  >
207
282
  {card.subtitle}
208
283
  </span>
209
284
  </span>
285
+ {hasLogos && (
286
+ <span
287
+ className="bold-text flex items-center whitespace-nowrap text-[#464647]"
288
+ // Clip sideways only, so the Remate logo shadow isn't cut off above and below
289
+ style={{ ...logoLayout.row, overflowX: "clip" }}
290
+ >
291
+ {visibleLogos.map((logo) => (
292
+ <img
293
+ key={logo.url}
294
+ src={logo.url}
295
+ alt={logo.name ?? ""}
296
+ className="block object-contain object-left"
297
+ style={{
298
+ ...logoLayout.logo,
299
+ ...(isRemateCard(card)
300
+ ? { filter: REMATE_LOGO_SHADOW }
301
+ : undefined),
302
+ }}
303
+ />
304
+ ))}
305
+ {visibleLogos.length < operatorLogos.length && (
306
+ <span style={{ flexShrink: 0 }}>
307
+ +{operatorLogos.length - visibleLogos.length}
308
+ </span>
309
+ )}
310
+ </span>
311
+ )}
210
312
  </button>
211
313
  );
212
314
  })}
@@ -8,6 +8,8 @@ import {
8
8
  const DESCUENTOS_ESPECIALES_KEY = "descuentos_especiales";
9
9
  const DESCUENTOS_ESPECIALES_LABEL = "Descuentos especiales";
10
10
  const REMATE_KEY = "remate";
11
+ const OPERATOR_LOGO_MIN_PERCENT = 30;
12
+ const OPERATOR_LOGO_CAMPAIGN_TYPE = "operator_logo";
11
13
 
12
14
  export const CAMPAIGN_TYPE_LABELS: Record<string, string> = {
13
15
  last_minute: "Oferta de último minuto",
@@ -112,6 +114,40 @@ function getServiceDiscount(
112
114
  return null;
113
115
  }
114
116
 
117
+ // The check uses the rounded amount so it agrees with the percent printed on the card.
118
+ function meetsLogoThreshold(type: ServiceDiscount["type"], amount: number) {
119
+ return type === "percentage" && Math.round(amount) >= OPERATOR_LOGO_MIN_PERCENT;
120
+ }
121
+
122
+ function showsOperatorLogo(
123
+ service: DiscountBannerServiceItem,
124
+ discount: ServiceDiscount,
125
+ ): boolean {
126
+ const campaign = service.discounts?.[0];
127
+ return (
128
+ !!campaign?.operator_logo ||
129
+ campaign?.campaign_type === OPERATOR_LOGO_CAMPAIGN_TYPE ||
130
+ meetsLogoThreshold(discount.type, discount.amount)
131
+ );
132
+ }
133
+
134
+ // Wow-deal services send operator_logo_url/operator_name; regular services send
135
+ // operator_details. A logo sent on the campaign wins over both.
136
+ function addOperatorLogo(
137
+ card: DiscountBannerCard,
138
+ service: DiscountBannerServiceItem,
139
+ ) {
140
+ const url =
141
+ service.discounts?.[0]?.operator_logo ||
142
+ service.operator_logo_url ||
143
+ service.operator_details?.[0];
144
+ if (!url || card.operatorLogos?.some((logo) => logo.url === url)) return;
145
+ card.operatorLogos = [
146
+ ...(card.operatorLogos ?? []),
147
+ { url, name: service.operator_name || service.operator_details?.[2] },
148
+ ];
149
+ }
150
+
115
151
  function toDiscountLabel(
116
152
  discount: ServiceDiscount,
117
153
  currencySign: string,
@@ -150,6 +186,11 @@ export function deriveDiscountCards(
150
186
  matchedServices: Array.isArray(wowServices) ? wowServices : [],
151
187
  isWowDeal: true,
152
188
  });
189
+ if (meetsLogoThreshold(remateDiscount.type, remateDiscount.amount)) {
190
+ for (const service of cards.get(REMATE_KEY)!.matchedServices) {
191
+ addOperatorLogo(cards.get(REMATE_KEY)!, service);
192
+ }
193
+ }
153
194
  }
154
195
 
155
196
  for (const service of services || []) {
@@ -158,6 +199,8 @@ export function deriveDiscountCards(
158
199
 
159
200
  const roundedAmount = Math.round(discount.amount);
160
201
 
202
+ const showsLogo = showsOperatorLogo(service, discount);
203
+
161
204
  // Backend-driven cards: art and text come from the service; services with the
162
205
  // same discount (and same campaign content) are grouped into one card.
163
206
  const backendCampaign = discount.isDp ? null : getBackendCampaign(service);
@@ -183,6 +226,7 @@ export function deriveDiscountCards(
183
226
  campaignTexts: texts,
184
227
  });
185
228
  }
229
+ if (showsLogo) addOperatorLogo(cards.get(key)!, service);
186
230
  continue;
187
231
  }
188
232
 
@@ -191,11 +235,14 @@ export function deriveDiscountCards(
191
235
  ? CAMPAIGN_TYPE_LABELS[campaignType]
192
236
  : undefined;
193
237
 
194
- const category = campaignType
195
- ? slugify(campaignType)
196
- : service.offer_text
197
- ? slugify(service.offer_text)
198
- : DESCUENTOS_ESPECIALES_KEY;
238
+ // Logo cards group purely by discount, so every operator offering the same percent
239
+ // lands on one banner even when their offer_text differs.
240
+ const category =
241
+ campaignType && campaignType !== OPERATOR_LOGO_CAMPAIGN_TYPE
242
+ ? slugify(campaignType)
243
+ : service.offer_text && !showsLogo
244
+ ? slugify(service.offer_text)
245
+ : DESCUENTOS_ESPECIALES_KEY;
199
246
  const subtitle = campaignLabel || DESCUENTOS_ESPECIALES_LABEL;
200
247
  const key = `${category}_${discount.type}_${roundedAmount}`;
201
248
 
@@ -217,6 +264,16 @@ export function deriveDiscountCards(
217
264
  isWowDeal: false,
218
265
  });
219
266
  }
267
+ if (showsLogo) addOperatorLogo(cards.get(key)!, service);
268
+ }
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
+ });
220
277
  }
221
278
 
222
279
  return Array.from(cards.values());
@@ -13,6 +13,7 @@ export interface DiscountBannerServiceItem {
13
13
  discounts?: Array<{
14
14
  new_ui_enabled?: boolean;
15
15
  campaign_type?: string;
16
+ operator_logo?: string;
16
17
  campaign_image_url?: string;
17
18
  campaign_text?: { text_1?: string; text_2?: string; text_3?: string };
18
19
  }>;
@@ -39,6 +40,9 @@ export interface DiscountBannerCard {
39
40
  // Present when the card's art and copy come from the backend
40
41
  campaignImageUrl?: string;
41
42
  campaignTexts?: { text1: string; text2: string; text3: string };
43
+ // One entry per operator on the card; set on percentage cards of 30% or more, or when
44
+ // the campaign asks for a logo
45
+ operatorLogos?: Array<{ url: string; name?: string }>;
42
46
  }
43
47
 
44
48
  export interface DiscountBannersProps {