kupos-ui-components-lib 10.4.68 → 10.4.69

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,6 +28,53 @@ 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%" };
32
79
  const REMATE_KEY = "remate";
33
80
  function isRemateCard(card) {
@@ -84,6 +131,7 @@ class DiscountBannersDesktop extends React.Component {
84
131
  }
85
132
  render() {
86
133
  const { services = [], wowDealData, backgroundImages = [], artIncludesChipPill, accentColors = [], label = DEFAULT_LABEL, currencySign = "$", size = "default", selectedKey, className = "", style, } = this.props;
134
+ console.log("🚀 ~ DiscountBannersDesktop ~ render ~ services:", services);
87
135
  // Compute cards directly — no useMemo
88
136
  const cards = deriveDiscountCards(services, wowDealData, currencySign);
89
137
  if (!cards.length)
@@ -96,8 +144,17 @@ class DiscountBannersDesktop extends React.Component {
96
144
  const defaultArt = resolveArt(cards);
97
145
  const chipPillInArt = artIncludesChipPill !== null && artIncludesChipPill !== void 0 ? artIncludesChipPill : usingDefaultArt;
98
146
  const sizeClasses = SIZE_CLASSES[size !== null && size !== void 0 ? size : "default"];
147
+ const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
148
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
99
149
  return (React.createElement("div", { className: `kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`, style: style }, cards.map((card, index) => {
150
+ var _a;
100
151
  const isActive = activeKey === card.key;
152
+ const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
153
+ const hasLogos = operatorLogos.length > 0;
154
+ // Three logos fill the space beside the art, so a "+N" takes the third slot
155
+ const visibleLogos = operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
156
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
157
+ : operatorLogos;
101
158
  const isDimmed = activeKey != null && !isActive;
102
159
  const backgroundImage = card.campaignImageUrl
103
160
  ? card.campaignImageUrl
@@ -114,25 +171,30 @@ class DiscountBannersDesktop extends React.Component {
114
171
  isFlameArt &&
115
172
  card.discountType === "percentage" &&
116
173
  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
174
+ 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
175
  ? `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
176
+ : undefined, backgroundSize: "100% 100%" }, (hasLogoRow ? logoLayout.card : undefined)) },
177
+ 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
178
  ? 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" }) },
179
+ : { backgroundColor: "rgba(255,255,255,0.55)" })), (hasLogoRow ? logoLayout.chip : undefined)) }, card.campaignTexts ? card.campaignTexts.text1 : label),
180
+ 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
181
  card.amount,
128
- React.createElement("span", { className: sizeClasses.flamePercent, style: { fontWeight: 700 } }, "%"))),
182
+ React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
129
183
  React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
130
- React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}` }, card.campaignTexts
184
+ React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.campaignTexts
131
185
  ? card.campaignTexts.text2
132
186
  : card.discountLabel),
133
- React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: { color: accentColor } }, card.campaignTexts
187
+ React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.campaignTexts
134
188
  ? card.campaignTexts.text3
135
- : card.subtitle))));
189
+ : card.subtitle)),
190
+ hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]", style: logoLayout.row },
191
+ visibleLogos.map((logo) => {
192
+ var _a;
193
+ return (React.createElement("img", { key: logo.url, src: logo.url, alt: (_a = logo.name) !== null && _a !== void 0 ? _a : "", className: "block object-contain object-left", style: logoLayout.logo }));
194
+ }),
195
+ visibleLogos.length < operatorLogos.length && (React.createElement("span", null,
196
+ "+",
197
+ operatorLogos.length - visibleLogos.length))))));
136
198
  })));
137
199
  }
138
200
  }
@@ -28,6 +28,53 @@ 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%" };
32
79
  const REMATE_KEY = "remate";
33
80
  function isRemateCard(card) {
@@ -95,8 +142,17 @@ class DiscountBannersMobile extends React.Component {
95
142
  const defaultArt = resolveArt(cards);
96
143
  const chipPillInArt = artIncludesChipPill !== null && artIncludesChipPill !== void 0 ? artIncludesChipPill : usingDefaultArt;
97
144
  const sizeClasses = SIZE_CLASSES[size !== null && size !== void 0 ? size : "default"];
145
+ const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
146
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
98
147
  return (React.createElement("div", { className: `kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`, style: style }, cards.map((card, index) => {
148
+ var _a;
99
149
  const isActive = activeKey === card.key;
150
+ const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
151
+ const hasLogos = operatorLogos.length > 0;
152
+ // Three logos fill the space beside the art, so a "+N" takes the third slot
153
+ const visibleLogos = operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
154
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
155
+ : operatorLogos;
100
156
  const isDimmed = activeKey != null && !isActive;
101
157
  const backgroundImage = card.campaignImageUrl
102
158
  ? card.campaignImageUrl
@@ -113,25 +169,30 @@ class DiscountBannersMobile extends React.Component {
113
169
  isFlameArt &&
114
170
  card.discountType === "percentage" &&
115
171
  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
172
+ 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
173
  ? `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
174
+ : undefined, backgroundSize: "100% 100%" }, (hasLogoRow ? logoLayout.card : undefined)) },
175
+ 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
176
  ? 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" }) },
177
+ : { backgroundColor: "rgba(255,255,255,0.55)" })), (hasLogoRow ? logoLayout.chip : undefined)) }, card.campaignTexts ? card.campaignTexts.text1 : label),
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)) },
126
179
  card.amount,
127
- React.createElement("span", { className: sizeClasses.flamePercent, style: { fontWeight: 700 } }, "%"))),
180
+ React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
128
181
  React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
129
- React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}` }, card.campaignTexts
182
+ React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.campaignTexts
130
183
  ? card.campaignTexts.text2
131
184
  : card.discountLabel),
132
- React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: { color: accentColor } }, card.campaignTexts
185
+ React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.campaignTexts
133
186
  ? card.campaignTexts.text3
134
- : card.subtitle))));
187
+ : card.subtitle)),
188
+ hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]", style: logoLayout.row },
189
+ visibleLogos.map((logo) => {
190
+ var _a;
191
+ return (React.createElement("img", { key: logo.url, src: logo.url, alt: (_a = logo.name) !== null && _a !== void 0 ? _a : "", className: "block object-contain object-left", style: logoLayout.logo }));
192
+ }),
193
+ visibleLogos.length < operatorLogos.length && (React.createElement("span", null,
194
+ "+",
195
+ operatorLogos.length - visibleLogos.length))))));
135
196
  })));
136
197
  }
137
198
  }
@@ -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,6 +28,53 @@ 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%" };
32
79
  const REMATE_KEY = "remate";
33
80
  function isRemateCard(card) {
@@ -78,8 +125,17 @@ const DiscountBanners = ({ services = [], wowDealData, backgroundImages = [], ar
78
125
  const defaultArt = resolveArt(cards);
79
126
  const chipPillInArt = artIncludesChipPill !== null && artIncludesChipPill !== void 0 ? artIncludesChipPill : usingDefaultArt;
80
127
  const sizeClasses = SIZE_CLASSES[size];
128
+ const hasLogoRow = cards.some((card) => { var _a; return (_a = card.operatorLogos) === null || _a === void 0 ? void 0 : _a.length; });
129
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size !== null && size !== void 0 ? size : "default"];
81
130
  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) => {
131
+ var _a;
82
132
  const isActive = activeKey === card.key;
133
+ const operatorLogos = (_a = card.operatorLogos) !== null && _a !== void 0 ? _a : [];
134
+ const hasLogos = operatorLogos.length > 0;
135
+ // Three logos fill the space beside the art, so a "+N" takes the third slot
136
+ const visibleLogos = operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
137
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
138
+ : operatorLogos;
83
139
  const isDimmed = activeKey != null && !isActive;
84
140
  const backgroundImage = card.campaignImageUrl
85
141
  ? card.campaignImageUrl
@@ -96,21 +152,26 @@ const DiscountBanners = ({ services = [], wowDealData, backgroundImages = [], ar
96
152
  isFlameArt &&
97
153
  card.discountType === "percentage" &&
98
154
  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
155
+ 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
156
  ? `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
157
+ : undefined, backgroundSize: "100% 100%" }, (hasLogoRow ? logoLayout.card : undefined)) },
158
+ 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
159
  ? 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" }) },
160
+ : { backgroundColor: "rgba(255,255,255,0.55)" })), (hasLogoRow ? logoLayout.chip : undefined)) }, card.campaignTexts ? card.campaignTexts.text1 : label),
161
+ 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
162
  card.amount,
110
- React.createElement("span", { className: sizeClasses.flamePercent, style: { fontWeight: 700 } }, "%"))),
163
+ React.createElement("span", { className: sizeClasses.flamePercent, style: Object.assign({ fontWeight: 700 }, (hasLogoRow ? logoLayout.flamePercent : undefined)) }, "%"))),
111
164
  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))));
165
+ React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}`, style: hasLogos ? logoLayout.discount : undefined }, card.discountLabel),
166
+ React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: Object.assign({ color: accentColor }, (hasLogos ? logoLayout.subtitle : undefined)) }, card.subtitle)),
167
+ hasLogos && (React.createElement("span", { className: "bold-text flex items-center whitespace-nowrap text-[#464647]", style: logoLayout.row },
168
+ visibleLogos.map((logo) => {
169
+ var _a;
170
+ return (React.createElement("img", { key: logo.url, src: logo.url, alt: (_a = logo.name) !== null && _a !== void 0 ? _a : "", className: "block object-contain object-left", style: logoLayout.logo }));
171
+ }),
172
+ visibleLogos.length < operatorLogos.length && (React.createElement("span", null,
173
+ "+",
174
+ operatorLogos.length - visibleLogos.length))))));
114
175
  })));
115
176
  };
116
177
  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`;
@@ -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,8 @@ export function deriveDiscountCards(services = [], wowDealData, currencySign = "
167
204
  isWowDeal: false,
168
205
  });
169
206
  }
207
+ if (showsLogo)
208
+ addOperatorLogo(cards.get(key), service);
170
209
  }
171
210
  return Array.from(cards.values());
172
211
  }
@@ -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.69",
4
4
  "description": "A reusable UI components package",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -38,6 +38,55 @@ 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
 
43
92
  const REMATE_KEY = "remate";
@@ -130,6 +179,7 @@ class DiscountBannersDesktop extends React.Component<
130
179
  className = "",
131
180
  style,
132
181
  } = this.props;
182
+ console.log("🚀 ~ DiscountBannersDesktop ~ render ~ services:", services)
133
183
 
134
184
  // Compute cards directly — no useMemo
135
185
  const cards = deriveDiscountCards(services, wowDealData, currencySign);
@@ -145,6 +195,8 @@ class DiscountBannersDesktop extends React.Component<
145
195
  const defaultArt = resolveArt(cards);
146
196
  const chipPillInArt = artIncludesChipPill ?? usingDefaultArt;
147
197
  const sizeClasses = SIZE_CLASSES[size ?? "default"];
198
+ const hasLogoRow = cards.some((card) => card.operatorLogos?.length);
199
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
148
200
 
149
201
  return (
150
202
  <div
@@ -153,6 +205,13 @@ class DiscountBannersDesktop extends React.Component<
153
205
  >
154
206
  {cards.map((card, index) => {
155
207
  const isActive = activeKey === card.key;
208
+ const operatorLogos = card.operatorLogos ?? [];
209
+ const hasLogos = operatorLogos.length > 0;
210
+ // Three logos fill the space beside the art, so a "+N" takes the third slot
211
+ const visibleLogos =
212
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
213
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
214
+ : operatorLogos;
156
215
  const isDimmed = activeKey != null && !isActive;
157
216
  const backgroundImage = card.campaignImageUrl
158
217
  ? card.campaignImageUrl
@@ -186,15 +245,17 @@ class DiscountBannersDesktop extends React.Component<
186
245
  ? `url(${backgroundImage})`
187
246
  : undefined,
188
247
  backgroundSize: "100% 100%",
248
+ ...(hasLogoRow ? logoLayout.card : undefined),
189
249
  }}
190
250
  >
191
251
  <span
192
252
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
193
- style={
194
- chipPillInArt || card.campaignImageUrl
253
+ style={{
254
+ ...(chipPillInArt || card.campaignImageUrl
195
255
  ? undefined
196
- : { backgroundColor: "rgba(255,255,255,0.55)" }
197
- }
256
+ : { backgroundColor: "rgba(255,255,255,0.55)" }),
257
+ ...(hasLogoRow ? logoLayout.chip : undefined),
258
+ }}
198
259
  >
199
260
  {card.campaignTexts ? card.campaignTexts.text1 : label}
200
261
  </span>
@@ -207,12 +268,16 @@ class DiscountBannersDesktop extends React.Component<
207
268
  textShadow: "0 1px 2px rgba(0,0,0,0.28)",
208
269
  fontWeight: 900,
209
270
  whiteSpace: "nowrap",
271
+ ...(hasLogoRow ? logoLayout.flameBadge : undefined),
210
272
  }}
211
273
  >
212
274
  {card.amount}
213
275
  <span
214
276
  className={sizeClasses.flamePercent}
215
- style={{ fontWeight: 700 }}
277
+ style={{
278
+ fontWeight: 700,
279
+ ...(hasLogoRow ? logoLayout.flamePercent : undefined),
280
+ }}
216
281
  >
217
282
  %
218
283
  </span>
@@ -221,6 +286,7 @@ class DiscountBannersDesktop extends React.Component<
221
286
  <span className={`flex flex-col ${sizeClasses.column}`}>
222
287
  <span
223
288
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
289
+ style={hasLogos ? logoLayout.discount : undefined}
224
290
  >
225
291
  {card.campaignTexts
226
292
  ? card.campaignTexts.text2
@@ -228,13 +294,35 @@ class DiscountBannersDesktop extends React.Component<
228
294
  </span>
229
295
  <span
230
296
  className={`bold-text ${sizeClasses.subtitle}`}
231
- style={{ color: accentColor }}
297
+ style={{
298
+ color: accentColor,
299
+ ...(hasLogos ? logoLayout.subtitle : undefined),
300
+ }}
232
301
  >
233
302
  {card.campaignTexts
234
303
  ? card.campaignTexts.text3
235
304
  : card.subtitle}
236
305
  </span>
237
306
  </span>
307
+ {hasLogos && (
308
+ <span
309
+ className="bold-text flex items-center whitespace-nowrap text-[#464647]"
310
+ style={logoLayout.row}
311
+ >
312
+ {visibleLogos.map((logo) => (
313
+ <img
314
+ key={logo.url}
315
+ src={logo.url}
316
+ alt={logo.name ?? ""}
317
+ className="block object-contain object-left"
318
+ style={logoLayout.logo}
319
+ />
320
+ ))}
321
+ {visibleLogos.length < operatorLogos.length && (
322
+ <span>+{operatorLogos.length - visibleLogos.length}</span>
323
+ )}
324
+ </span>
325
+ )}
238
326
  </button>
239
327
  );
240
328
  })}
@@ -38,6 +38,55 @@ 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
 
43
92
  const REMATE_KEY = "remate";
@@ -139,6 +188,8 @@ class DiscountBannersMobile extends React.Component<
139
188
  const defaultArt = resolveArt(cards);
140
189
  const chipPillInArt = artIncludesChipPill ?? usingDefaultArt;
141
190
  const sizeClasses = SIZE_CLASSES[size ?? "default"];
191
+ const hasLogoRow = cards.some((card) => card.operatorLogos?.length);
192
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
142
193
 
143
194
  return (
144
195
  <div
@@ -147,6 +198,13 @@ class DiscountBannersMobile extends React.Component<
147
198
  >
148
199
  {cards.map((card, index) => {
149
200
  const isActive = activeKey === card.key;
201
+ const operatorLogos = card.operatorLogos ?? [];
202
+ const hasLogos = operatorLogos.length > 0;
203
+ // Three logos fill the space beside the art, so a "+N" takes the third slot
204
+ const visibleLogos =
205
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
206
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
207
+ : operatorLogos;
150
208
  const isDimmed = activeKey != null && !isActive;
151
209
  const backgroundImage = card.campaignImageUrl
152
210
  ? card.campaignImageUrl
@@ -180,15 +238,17 @@ class DiscountBannersMobile extends React.Component<
180
238
  ? `url(${backgroundImage})`
181
239
  : undefined,
182
240
  backgroundSize: "100% 100%",
241
+ ...(hasLogoRow ? logoLayout.card : undefined),
183
242
  }}
184
243
  >
185
244
  <span
186
245
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
187
- style={
188
- chipPillInArt || card.campaignImageUrl
246
+ style={{
247
+ ...(chipPillInArt || card.campaignImageUrl
189
248
  ? undefined
190
- : { backgroundColor: "rgba(255,255,255,0.55)" }
191
- }
249
+ : { backgroundColor: "rgba(255,255,255,0.55)" }),
250
+ ...(hasLogoRow ? logoLayout.chip : undefined),
251
+ }}
192
252
  >
193
253
  {card.campaignTexts ? card.campaignTexts.text1 : label}
194
254
  </span>
@@ -201,12 +261,16 @@ class DiscountBannersMobile extends React.Component<
201
261
  textShadow: "0 1px 2px rgba(0,0,0,0.28)",
202
262
  fontWeight: 900,
203
263
  whiteSpace: "nowrap",
264
+ ...(hasLogoRow ? logoLayout.flameBadge : undefined),
204
265
  }}
205
266
  >
206
267
  {card.amount}
207
268
  <span
208
269
  className={sizeClasses.flamePercent}
209
- style={{ fontWeight: 700 }}
270
+ style={{
271
+ fontWeight: 700,
272
+ ...(hasLogoRow ? logoLayout.flamePercent : undefined),
273
+ }}
210
274
  >
211
275
  %
212
276
  </span>
@@ -215,6 +279,7 @@ class DiscountBannersMobile extends React.Component<
215
279
  <span className={`flex flex-col ${sizeClasses.column}`}>
216
280
  <span
217
281
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
282
+ style={hasLogos ? logoLayout.discount : undefined}
218
283
  >
219
284
  {card.campaignTexts
220
285
  ? card.campaignTexts.text2
@@ -222,13 +287,35 @@ class DiscountBannersMobile extends React.Component<
222
287
  </span>
223
288
  <span
224
289
  className={`bold-text ${sizeClasses.subtitle}`}
225
- style={{ color: accentColor }}
290
+ style={{
291
+ color: accentColor,
292
+ ...(hasLogos ? logoLayout.subtitle : undefined),
293
+ }}
226
294
  >
227
295
  {card.campaignTexts
228
296
  ? card.campaignTexts.text3
229
297
  : card.subtitle}
230
298
  </span>
231
299
  </span>
300
+ {hasLogos && (
301
+ <span
302
+ className="bold-text flex items-center whitespace-nowrap text-[#464647]"
303
+ style={logoLayout.row}
304
+ >
305
+ {visibleLogos.map((logo) => (
306
+ <img
307
+ key={logo.url}
308
+ src={logo.url}
309
+ alt={logo.name ?? ""}
310
+ className="block object-contain object-left"
311
+ style={logoLayout.logo}
312
+ />
313
+ ))}
314
+ {visibleLogos.length < operatorLogos.length && (
315
+ <span>+{operatorLogos.length - visibleLogos.length}</span>
316
+ )}
317
+ </span>
318
+ )}
232
319
  </button>
233
320
  );
234
321
  })}
@@ -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,6 +37,55 @@ 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
 
42
91
  const REMATE_KEY = "remate";
@@ -121,6 +170,8 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
121
170
  const defaultArt = resolveArt(cards);
122
171
  const chipPillInArt = artIncludesChipPill ?? usingDefaultArt;
123
172
  const sizeClasses = SIZE_CLASSES[size];
173
+ const hasLogoRow = cards.some((card) => card.operatorLogos?.length);
174
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
124
175
 
125
176
  return (
126
177
  <div
@@ -129,6 +180,13 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
129
180
  >
130
181
  {cards.map((card, index) => {
131
182
  const isActive = activeKey === card.key;
183
+ const operatorLogos = card.operatorLogos ?? [];
184
+ const hasLogos = operatorLogos.length > 0;
185
+ // Three logos fill the space beside the art, so a "+N" takes the third slot
186
+ const visibleLogos =
187
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
188
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
189
+ : operatorLogos;
132
190
  const isDimmed = activeKey != null && !isActive;
133
191
  const backgroundImage = card.campaignImageUrl
134
192
  ? card.campaignImageUrl
@@ -162,15 +220,17 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
162
220
  ? `url(${backgroundImage})`
163
221
  : undefined,
164
222
  backgroundSize: "100% 100%",
223
+ ...(hasLogoRow ? logoLayout.card : undefined),
165
224
  }}
166
225
  >
167
226
  <span
168
227
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
169
- style={
170
- chipPillInArt || card.campaignImageUrl
228
+ style={{
229
+ ...(chipPillInArt || card.campaignImageUrl
171
230
  ? undefined
172
- : { backgroundColor: "rgba(255,255,255,0.55)" }
173
- }
231
+ : { backgroundColor: "rgba(255,255,255,0.55)" }),
232
+ ...(hasLogoRow ? logoLayout.chip : undefined),
233
+ }}
174
234
  >
175
235
  {card.campaignTexts ? card.campaignTexts.text1 : label}
176
236
  </span>
@@ -183,12 +243,16 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
183
243
  textShadow: "0 1px 2px rgba(0,0,0,0.28)",
184
244
  fontWeight: 900,
185
245
  whiteSpace: "nowrap",
246
+ ...(hasLogoRow ? logoLayout.flameBadge : undefined),
186
247
  }}
187
248
  >
188
249
  {card.amount}
189
250
  <span
190
251
  className={sizeClasses.flamePercent}
191
- style={{ fontWeight: 700 }}
252
+ style={{
253
+ fontWeight: 700,
254
+ ...(hasLogoRow ? logoLayout.flamePercent : undefined),
255
+ }}
192
256
  >
193
257
  %
194
258
  </span>
@@ -197,16 +261,39 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
197
261
  <span className={`flex flex-col ${sizeClasses.column}`}>
198
262
  <span
199
263
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
264
+ style={hasLogos ? logoLayout.discount : undefined}
200
265
  >
201
266
  {card.discountLabel}
202
267
  </span>
203
268
  <span
204
269
  className={`bold-text ${sizeClasses.subtitle}`}
205
- style={{ color: accentColor }}
270
+ style={{
271
+ color: accentColor,
272
+ ...(hasLogos ? logoLayout.subtitle : undefined),
273
+ }}
206
274
  >
207
275
  {card.subtitle}
208
276
  </span>
209
277
  </span>
278
+ {hasLogos && (
279
+ <span
280
+ className="bold-text flex items-center whitespace-nowrap text-[#464647]"
281
+ style={logoLayout.row}
282
+ >
283
+ {visibleLogos.map((logo) => (
284
+ <img
285
+ key={logo.url}
286
+ src={logo.url}
287
+ alt={logo.name ?? ""}
288
+ className="block object-contain object-left"
289
+ style={logoLayout.logo}
290
+ />
291
+ ))}
292
+ {visibleLogos.length < operatorLogos.length && (
293
+ <span>+{operatorLogos.length - visibleLogos.length}</span>
294
+ )}
295
+ </span>
296
+ )}
210
297
  </button>
211
298
  );
212
299
  })}
@@ -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,7 @@ export function deriveDiscountCards(
217
264
  isWowDeal: false,
218
265
  });
219
266
  }
267
+ if (showsLogo) addOperatorLogo(cards.get(key)!, service);
220
268
  }
221
269
 
222
270
  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 {