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.
- package/dist/components/DiscountBanners/DiscountBannerDesktop.js +83 -12
- package/dist/components/DiscountBanners/DiscountBannerMobile.js +82 -12
- package/dist/components/DiscountBanners/types.d.ts +5 -0
- package/dist/styles.css +4 -0
- package/dist/ui/DiscountBanners/DiscountBanners.js +81 -11
- package/dist/ui/DiscountBanners/discountUtils.js +52 -3
- package/dist/ui/DiscountBanners/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/components/DiscountBanners/DiscountBannerDesktop.tsx +109 -6
- package/src/components/DiscountBanners/DiscountBannerMobile.tsx +108 -6
- package/src/components/DiscountBanners/types.ts +4 -0
- package/src/ui/DiscountBanners/DiscountBanners.tsx +108 -6
- package/src/ui/DiscountBanners/discountUtils.ts +62 -5
- package/src/ui/DiscountBanners/types.ts +4 -0
|
@@ -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
|
-
|
|
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}
|
|
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
|
-
|
|
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}
|
|
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
|
@@ -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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
@@ -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={{
|
|
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={{
|
|
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={{
|
|
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={{
|
|
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={{
|
|
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={{
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 {
|