kupos-ui-components-lib 10.4.55 → 10.4.57

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.
@@ -99,9 +99,11 @@ class DiscountBannersDesktop extends React.Component {
99
99
  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) => {
100
100
  const isActive = activeKey === card.key;
101
101
  const isDimmed = activeKey != null && !isActive;
102
- const backgroundImage = usingDefaultArt
103
- ? defaultArt[index].image
104
- : toImageUrl(backgroundImages[index % backgroundImages.length]);
102
+ const backgroundImage = card.campaignImageUrl
103
+ ? card.campaignImageUrl
104
+ : usingDefaultArt
105
+ ? defaultArt[index].image
106
+ : toImageUrl(backgroundImages[index % backgroundImages.length]);
105
107
  const accentColor = accentColors.length
106
108
  ? accentColors[index % accentColors.length]
107
109
  : usingDefaultArt
@@ -118,15 +120,19 @@ class DiscountBannersDesktop extends React.Component {
118
120
  : undefined,
119
121
  backgroundSize: "100% 100%",
120
122
  } },
121
- React.createElement("span", { className: `bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`, style: chipPillInArt
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
122
124
  ? undefined
123
- : { backgroundColor: "rgba(255,255,255,0.55)" } }, label),
125
+ : { backgroundColor: "rgba(255,255,255,0.55)" } }, card.campaignTexts ? card.campaignTexts.text1 : label),
124
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" }) },
125
127
  card.amount,
126
128
  React.createElement("span", { className: sizeClasses.flamePercent, style: { fontWeight: 700 } }, "%"))),
127
129
  React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
128
- React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}` }, card.discountLabel),
129
- React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: { color: accentColor } }, card.subtitle))));
130
+ React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}` }, card.campaignTexts
131
+ ? card.campaignTexts.text2
132
+ : card.discountLabel),
133
+ React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: { color: accentColor } }, card.campaignTexts
134
+ ? card.campaignTexts.text3
135
+ : card.subtitle))));
130
136
  })));
131
137
  }
132
138
  }
@@ -98,9 +98,11 @@ class DiscountBannersMobile extends React.Component {
98
98
  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) => {
99
99
  const isActive = activeKey === card.key;
100
100
  const isDimmed = activeKey != null && !isActive;
101
- const backgroundImage = usingDefaultArt
102
- ? defaultArt[index].image
103
- : toImageUrl(backgroundImages[index % backgroundImages.length]);
101
+ const backgroundImage = card.campaignImageUrl
102
+ ? card.campaignImageUrl
103
+ : usingDefaultArt
104
+ ? defaultArt[index].image
105
+ : toImageUrl(backgroundImages[index % backgroundImages.length]);
104
106
  const accentColor = accentColors.length
105
107
  ? accentColors[index % accentColors.length]
106
108
  : usingDefaultArt
@@ -117,15 +119,19 @@ class DiscountBannersMobile extends React.Component {
117
119
  : undefined,
118
120
  backgroundSize: "100% 100%",
119
121
  } },
120
- React.createElement("span", { className: `bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`, style: chipPillInArt
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
121
123
  ? undefined
122
- : { backgroundColor: "rgba(255,255,255,0.55)" } }, label),
124
+ : { backgroundColor: "rgba(255,255,255,0.55)" } }, card.campaignTexts ? card.campaignTexts.text1 : label),
123
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" }) },
124
126
  card.amount,
125
127
  React.createElement("span", { className: sizeClasses.flamePercent, style: { fontWeight: 700 } }, "%"))),
126
128
  React.createElement("span", { className: `flex flex-col ${sizeClasses.column}` },
127
- React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}` }, card.discountLabel),
128
- React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: { color: accentColor } }, card.subtitle))));
129
+ React.createElement("span", { className: `bold-text text-[#464647] ${sizeClasses.discount}` }, card.campaignTexts
130
+ ? card.campaignTexts.text2
131
+ : card.discountLabel),
132
+ React.createElement("span", { className: `bold-text ${sizeClasses.subtitle}`, style: { color: accentColor } }, card.campaignTexts
133
+ ? card.campaignTexts.text3
134
+ : card.subtitle))));
129
135
  })));
130
136
  }
131
137
  }
@@ -12,6 +12,12 @@ export interface DiscountBannerServiceItem {
12
12
  discounts?: Array<{
13
13
  new_ui_enabled?: boolean;
14
14
  campaign_type?: string;
15
+ campaign_image_url?: string;
16
+ campaign_text?: {
17
+ text_1?: string;
18
+ text_2?: string;
19
+ text_3?: string;
20
+ };
15
21
  }>;
16
22
  [key: string]: any;
17
23
  }
@@ -31,6 +37,12 @@ export interface DiscountBannerCard {
31
37
  discountLabel: string;
32
38
  matchedServices: DiscountBannerServiceItem[];
33
39
  isWowDeal: boolean;
40
+ campaignImageUrl?: string;
41
+ campaignTexts?: {
42
+ text1: string;
43
+ text2: string;
44
+ text3: string;
45
+ };
34
46
  }
35
47
  export interface DiscountBannersProps {
36
48
  services?: DiscountBannerServiceItem[];
@@ -470,11 +470,12 @@ const PaymentSideBarDesktop = ({ serviceNameOnward, serviceNameReturn, metaData,
470
470
  justifyContent: "flex-end",
471
471
  } },
472
472
  currency ? currency : "",
473
+ " ",
473
474
  CommonService.currencyExact(isPeru
474
475
  ? Math.round((removeDiscountAtomValue ? 0 : netFare) * 100) /
475
476
  100
476
477
  : removeDiscountAtomValue
477
478
  ? 0
478
- : netFare)))))));
479
+ : netFare, currencySign)))))));
479
480
  };
480
481
  export default PaymentSideBarDesktop;
@@ -81,9 +81,11 @@ const DiscountBanners = ({ services = [], wowDealData, backgroundImages = [], ar
81
81
  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) => {
82
82
  const isActive = activeKey === card.key;
83
83
  const isDimmed = activeKey != null && !isActive;
84
- const backgroundImage = usingDefaultArt
85
- ? defaultArt[index].image
86
- : toImageUrl(backgroundImages[index % backgroundImages.length]);
84
+ const backgroundImage = card.campaignImageUrl
85
+ ? card.campaignImageUrl
86
+ : usingDefaultArt
87
+ ? defaultArt[index].image
88
+ : toImageUrl(backgroundImages[index % backgroundImages.length]);
87
89
  const accentColor = accentColors.length
88
90
  ? accentColors[index % accentColors.length]
89
91
  : usingDefaultArt
@@ -100,9 +102,9 @@ const DiscountBanners = ({ services = [], wowDealData, backgroundImages = [], ar
100
102
  : undefined,
101
103
  backgroundSize: "100% 100%",
102
104
  } },
103
- React.createElement("span", { className: `bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`, style: chipPillInArt
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
104
106
  ? undefined
105
- : { backgroundColor: "rgba(255,255,255,0.55)" } }, label),
107
+ : { backgroundColor: "rgba(255,255,255,0.55)" } }, card.campaignTexts ? card.campaignTexts.text1 : label),
106
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" }) },
107
109
  card.amount,
108
110
  React.createElement("span", { className: sizeClasses.flamePercent, style: { fontWeight: 700 } }, "%"))),
@@ -30,6 +30,23 @@ function slugify(text) {
30
30
  .replace(/[^a-z0-9]+/g, "_")
31
31
  .replace(/^_+|_+$/g, "");
32
32
  }
33
+ // Backend-driven campaign art and copy (campaign_image_url + campaign_text.text_1..3)
34
+ function getBackendCampaign(service) {
35
+ var _a, _b, _c, _d;
36
+ const campaign = (_a = service === null || service === void 0 ? void 0 : service.discounts) === null || _a === void 0 ? void 0 : _a[0];
37
+ const imageUrl = campaign === null || campaign === void 0 ? void 0 : campaign.campaign_image_url;
38
+ const text = campaign === null || campaign === void 0 ? void 0 : campaign.campaign_text;
39
+ if (!imageUrl || !text)
40
+ return null;
41
+ return {
42
+ imageUrl,
43
+ texts: {
44
+ text1: (_b = text.text_1) !== null && _b !== void 0 ? _b : "",
45
+ text2: (_c = text.text_2) !== null && _c !== void 0 ? _c : "",
46
+ text3: (_d = text.text_3) !== null && _d !== void 0 ? _d : "",
47
+ },
48
+ };
49
+ }
33
50
  // Dynamic-pricing discounts store one percent per seat type; the best (lowest) one is what's advertised elsewhere in this library
34
51
  function getDpPercent(service) {
35
52
  if (!(service === null || service === void 0 ? void 0 : service.is_dp_enabled) || !(service === null || service === void 0 ? void 0 : service.dp_discount_percents))
@@ -42,7 +59,7 @@ function getDpPercent(service) {
42
59
  function getServiceDiscount(service) {
43
60
  const dpPercent = getDpPercent(service);
44
61
  if (dpPercent > 0)
45
- return { type: "percentage", amount: dpPercent };
62
+ return { type: "percentage", amount: dpPercent, isDp: true };
46
63
  if ((service === null || service === void 0 ? void 0 : service.discount_type) === "percentage" &&
47
64
  (service === null || service === void 0 ? void 0 : service.discount_value) != null &&
48
65
  service.discount_value > 0 &&
@@ -71,7 +88,7 @@ function toDiscountLabel(discount, currencySign) {
71
88
  }
72
89
  // Groups services into one card per discount category. Remate/wow-deal is always the first card.
73
90
  export function deriveDiscountCards(services = [], wowDealData, currencySign = "$") {
74
- var _a, _b;
91
+ var _a, _b, _c, _d;
75
92
  const cards = new Map();
76
93
  const wowServices = wowDealData === null || wowDealData === void 0 ? void 0 : wowDealData.services;
77
94
  const hasWowDeal = !!wowDealData &&
@@ -96,7 +113,34 @@ export function deriveDiscountCards(services = [], wowDealData, currencySign = "
96
113
  const discount = getServiceDiscount(service);
97
114
  if (!discount)
98
115
  continue;
99
- const campaignType = (_b = (_a = service.discounts) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.campaign_type;
116
+ const roundedAmount = Math.round(discount.amount);
117
+ // Backend-driven cards: art and text come from the service; services with the
118
+ // same discount (and same campaign content) are grouped into one card.
119
+ const backendCampaign = discount.isDp ? null : getBackendCampaign(service);
120
+ if (backendCampaign) {
121
+ const { imageUrl, texts } = backendCampaign;
122
+ const key = `campaign_${discount.type}_${roundedAmount}_${slugify(`${texts.text1} ${texts.text2} ${texts.text3}`)}_${imageUrl}`;
123
+ const existing = cards.get(key);
124
+ if (existing) {
125
+ existing.matchedServices.push(service);
126
+ }
127
+ else {
128
+ cards.set(key, {
129
+ key,
130
+ subtitle: texts.text3,
131
+ campaignType: (_b = (_a = service.discounts) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.campaign_type,
132
+ discountType: discount.type,
133
+ amount: roundedAmount,
134
+ discountLabel: texts.text2,
135
+ matchedServices: [service],
136
+ isWowDeal: false,
137
+ campaignImageUrl: imageUrl,
138
+ campaignTexts: texts,
139
+ });
140
+ }
141
+ continue;
142
+ }
143
+ const campaignType = (_d = (_c = service.discounts) === null || _c === void 0 ? void 0 : _c[0]) === null || _d === void 0 ? void 0 : _d.campaign_type;
100
144
  const campaignLabel = campaignType
101
145
  ? CAMPAIGN_TYPE_LABELS[campaignType]
102
146
  : undefined;
@@ -106,7 +150,6 @@ export function deriveDiscountCards(services = [], wowDealData, currencySign = "
106
150
  ? slugify(service.offer_text)
107
151
  : DESCUENTOS_ESPECIALES_KEY;
108
152
  const subtitle = campaignLabel || DESCUENTOS_ESPECIALES_LABEL;
109
- const roundedAmount = Math.round(discount.amount);
110
153
  const key = `${category}_${discount.type}_${roundedAmount}`;
111
154
  const existing = cards.get(key);
112
155
  if (existing) {
@@ -12,6 +12,12 @@ export interface DiscountBannerServiceItem {
12
12
  discounts?: Array<{
13
13
  new_ui_enabled?: boolean;
14
14
  campaign_type?: string;
15
+ campaign_image_url?: string;
16
+ campaign_text?: {
17
+ text_1?: string;
18
+ text_2?: string;
19
+ text_3?: string;
20
+ };
15
21
  }>;
16
22
  [key: string]: any;
17
23
  }
@@ -31,6 +37,12 @@ export interface DiscountBannerCard {
31
37
  discountLabel: string;
32
38
  matchedServices: DiscountBannerServiceItem[];
33
39
  isWowDeal: boolean;
40
+ campaignImageUrl?: string;
41
+ campaignTexts?: {
42
+ text1: string;
43
+ text2: string;
44
+ text3: string;
45
+ };
34
46
  }
35
47
  export interface DiscountBannersProps {
36
48
  services?: DiscountBannerServiceItem[];
@@ -1,6 +1,6 @@
1
1
  declare const commonService: {
2
2
  currency(amount: number, currencySign?: string): string;
3
- currencyExact: (amount: any) => string;
3
+ currencyExact: (amount: number, currencySign?: string) => string;
4
4
  currencyFixed(amount: number, currencySign?: string, decimals?: number): string;
5
5
  currencyRounded(amount: number, currencySign?: string): string;
6
6
  discountedCurrency(amount: number, currencySign?: string): string;
@@ -13,13 +13,14 @@ const commonService = {
13
13
  const sign = currencySign || "$";
14
14
  return sign + formattedAmount;
15
15
  },
16
- currencyExact: (amount) => {
16
+ currencyExact: (amount, currencySign) => {
17
+ const sign = currencySign || "$";
17
18
  if (amount === null || amount === undefined || isNaN(amount))
18
- return "S/0";
19
+ return sign + "0";
19
20
  const rounded = Math.round(+amount * 100) / 100;
20
21
  const parts = String(rounded).split(".");
21
22
  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ".");
22
- return "S/" + parts.join(".");
23
+ return sign + parts.join(".");
23
24
  },
24
25
  currencyFixed(amount, currencySign, decimals = 2) {
25
26
  const formattedAmount = fixFloatPrecision(amount, decimals)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kupos-ui-components-lib",
3
- "version": "10.4.55",
3
+ "version": "10.4.57",
4
4
  "description": "A reusable UI components package",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -154,9 +154,11 @@ class DiscountBannersDesktop extends React.Component<
154
154
  {cards.map((card, index) => {
155
155
  const isActive = activeKey === card.key;
156
156
  const isDimmed = activeKey != null && !isActive;
157
- const backgroundImage = usingDefaultArt
158
- ? defaultArt[index].image
159
- : toImageUrl(backgroundImages[index % backgroundImages.length]);
157
+ const backgroundImage = card.campaignImageUrl
158
+ ? card.campaignImageUrl
159
+ : usingDefaultArt
160
+ ? defaultArt[index].image
161
+ : toImageUrl(backgroundImages[index % backgroundImages.length]);
160
162
  const accentColor = accentColors.length
161
163
  ? accentColors[index % accentColors.length]
162
164
  : usingDefaultArt
@@ -189,12 +191,12 @@ class DiscountBannersDesktop extends React.Component<
189
191
  <span
190
192
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
191
193
  style={
192
- chipPillInArt
194
+ chipPillInArt || card.campaignImageUrl
193
195
  ? undefined
194
196
  : { backgroundColor: "rgba(255,255,255,0.55)" }
195
197
  }
196
198
  >
197
- {label}
199
+ {card.campaignTexts ? card.campaignTexts.text1 : label}
198
200
  </span>
199
201
  {showFlamePercent && (
200
202
  <span
@@ -220,13 +222,17 @@ class DiscountBannersDesktop extends React.Component<
220
222
  <span
221
223
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
222
224
  >
223
- {card.discountLabel}
225
+ {card.campaignTexts
226
+ ? card.campaignTexts.text2
227
+ : card.discountLabel}
224
228
  </span>
225
229
  <span
226
230
  className={`bold-text ${sizeClasses.subtitle}`}
227
231
  style={{ color: accentColor }}
228
232
  >
229
- {card.subtitle}
233
+ {card.campaignTexts
234
+ ? card.campaignTexts.text3
235
+ : card.subtitle}
230
236
  </span>
231
237
  </span>
232
238
  </button>
@@ -148,9 +148,11 @@ class DiscountBannersMobile extends React.Component<
148
148
  {cards.map((card, index) => {
149
149
  const isActive = activeKey === card.key;
150
150
  const isDimmed = activeKey != null && !isActive;
151
- const backgroundImage = usingDefaultArt
152
- ? defaultArt[index].image
153
- : toImageUrl(backgroundImages[index % backgroundImages.length]);
151
+ const backgroundImage = card.campaignImageUrl
152
+ ? card.campaignImageUrl
153
+ : usingDefaultArt
154
+ ? defaultArt[index].image
155
+ : toImageUrl(backgroundImages[index % backgroundImages.length]);
154
156
  const accentColor = accentColors.length
155
157
  ? accentColors[index % accentColors.length]
156
158
  : usingDefaultArt
@@ -183,12 +185,12 @@ class DiscountBannersMobile extends React.Component<
183
185
  <span
184
186
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
185
187
  style={
186
- chipPillInArt
188
+ chipPillInArt || card.campaignImageUrl
187
189
  ? undefined
188
190
  : { backgroundColor: "rgba(255,255,255,0.55)" }
189
191
  }
190
192
  >
191
- {label}
193
+ {card.campaignTexts ? card.campaignTexts.text1 : label}
192
194
  </span>
193
195
  {showFlamePercent && (
194
196
  <span
@@ -214,13 +216,17 @@ class DiscountBannersMobile extends React.Component<
214
216
  <span
215
217
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
216
218
  >
217
- {card.discountLabel}
219
+ {card.campaignTexts
220
+ ? card.campaignTexts.text2
221
+ : card.discountLabel}
218
222
  </span>
219
223
  <span
220
224
  className={`bold-text ${sizeClasses.subtitle}`}
221
225
  style={{ color: accentColor }}
222
226
  >
223
- {card.subtitle}
227
+ {card.campaignTexts
228
+ ? card.campaignTexts.text3
229
+ : card.subtitle}
224
230
  </span>
225
231
  </span>
226
232
  </button>
@@ -10,7 +10,12 @@ export interface DiscountBannerServiceItem {
10
10
  discount_type?: string;
11
11
  discount_value?: number;
12
12
  max_discount?: number;
13
- discounts?: Array<{ new_ui_enabled?: boolean; campaign_type?: string }>;
13
+ discounts?: Array<{
14
+ new_ui_enabled?: boolean;
15
+ campaign_type?: string;
16
+ campaign_image_url?: string;
17
+ campaign_text?: { text_1?: string; text_2?: string; text_3?: string };
18
+ }>;
14
19
  [key: string]: any;
15
20
  }
16
21
 
@@ -31,6 +36,9 @@ export interface DiscountBannerCard {
31
36
  discountLabel: string;
32
37
  matchedServices: DiscountBannerServiceItem[];
33
38
  isWowDeal: boolean;
39
+ // Present when the card's art and copy come from the backend
40
+ campaignImageUrl?: string;
41
+ campaignTexts?: { text1: string; text2: string; text3: string };
34
42
  }
35
43
 
36
44
  export interface DiscountBannersProps {
@@ -914,8 +914,7 @@ const PaymentSideBarDesktop: React.FC<PaymentSideBarProps> = ({
914
914
  justifyContent: "flex-end",
915
915
  }}
916
916
  >
917
- {currency ? currency : ""}
918
-
917
+ {currency ? currency : ""}{" "}
919
918
  {CommonService.currencyExact(
920
919
  isPeru
921
920
  ? Math.round((removeDiscountAtomValue ? 0 : netFare) * 100) /
@@ -923,6 +922,7 @@ const PaymentSideBarDesktop: React.FC<PaymentSideBarProps> = ({
923
922
  : removeDiscountAtomValue
924
923
  ? 0
925
924
  : netFare,
925
+ currencySign,
926
926
  )}
927
927
  </div>
928
928
  </div>
@@ -95,9 +95,9 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
95
95
  [services, wowDealData, currencySign],
96
96
  );
97
97
 
98
- const [internalSelectedKey, setInternalSelectedKey] = useState<
99
- string | null
100
- >(defaultSelectedKey);
98
+ const [internalSelectedKey, setInternalSelectedKey] = useState<string | null>(
99
+ defaultSelectedKey,
100
+ );
101
101
 
102
102
  const isControlled = selectedKey !== undefined;
103
103
  const activeKey = isControlled ? selectedKey : internalSelectedKey;
@@ -130,9 +130,11 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
130
130
  {cards.map((card, index) => {
131
131
  const isActive = activeKey === card.key;
132
132
  const isDimmed = activeKey != null && !isActive;
133
- const backgroundImage = usingDefaultArt
134
- ? defaultArt[index].image
135
- : toImageUrl(backgroundImages[index % backgroundImages.length]);
133
+ const backgroundImage = card.campaignImageUrl
134
+ ? card.campaignImageUrl
135
+ : usingDefaultArt
136
+ ? defaultArt[index].image
137
+ : toImageUrl(backgroundImages[index % backgroundImages.length]);
136
138
  const accentColor = accentColors.length
137
139
  ? accentColors[index % accentColors.length]
138
140
  : usingDefaultArt
@@ -165,12 +167,12 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
165
167
  <span
166
168
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
167
169
  style={
168
- chipPillInArt
170
+ chipPillInArt || card.campaignImageUrl
169
171
  ? undefined
170
172
  : { backgroundColor: "rgba(255,255,255,0.55)" }
171
173
  }
172
174
  >
173
- {label}
175
+ {card.campaignTexts ? card.campaignTexts.text1 : label}
174
176
  </span>
175
177
  {showFlamePercent && (
176
178
  <span
@@ -42,6 +42,30 @@ function slugify(text: string): string {
42
42
  interface ServiceDiscount {
43
43
  type: "percentage" | "fixed";
44
44
  amount: number;
45
+ isDp?: boolean;
46
+ }
47
+
48
+ interface BackendCampaign {
49
+ imageUrl: string;
50
+ texts: { text1: string; text2: string; text3: string };
51
+ }
52
+
53
+ // Backend-driven campaign art and copy (campaign_image_url + campaign_text.text_1..3)
54
+ function getBackendCampaign(
55
+ service: DiscountBannerServiceItem,
56
+ ): BackendCampaign | null {
57
+ const campaign = service?.discounts?.[0];
58
+ const imageUrl = campaign?.campaign_image_url;
59
+ const text = campaign?.campaign_text;
60
+ if (!imageUrl || !text) return null;
61
+ return {
62
+ imageUrl,
63
+ texts: {
64
+ text1: text.text_1 ?? "",
65
+ text2: text.text_2 ?? "",
66
+ text3: text.text_3 ?? "",
67
+ },
68
+ };
45
69
  }
46
70
 
47
71
  // Dynamic-pricing discounts store one percent per seat type; the best (lowest) one is what's advertised elsewhere in this library
@@ -57,7 +81,8 @@ function getServiceDiscount(
57
81
  service: DiscountBannerServiceItem,
58
82
  ): ServiceDiscount | null {
59
83
  const dpPercent = getDpPercent(service);
60
- if (dpPercent > 0) return { type: "percentage", amount: dpPercent };
84
+ if (dpPercent > 0)
85
+ return { type: "percentage", amount: dpPercent, isDp: true };
61
86
 
62
87
  if (
63
88
  service?.discount_type === "percentage" &&
@@ -131,6 +156,36 @@ export function deriveDiscountCards(
131
156
  const discount = getServiceDiscount(service);
132
157
  if (!discount) continue;
133
158
 
159
+ const roundedAmount = Math.round(discount.amount);
160
+
161
+ // Backend-driven cards: art and text come from the service; services with the
162
+ // same discount (and same campaign content) are grouped into one card.
163
+ const backendCampaign = discount.isDp ? null : getBackendCampaign(service);
164
+ if (backendCampaign) {
165
+ const { imageUrl, texts } = backendCampaign;
166
+ const key = `campaign_${discount.type}_${roundedAmount}_${slugify(
167
+ `${texts.text1} ${texts.text2} ${texts.text3}`,
168
+ )}_${imageUrl}`;
169
+ const existing = cards.get(key);
170
+ if (existing) {
171
+ existing.matchedServices.push(service);
172
+ } else {
173
+ cards.set(key, {
174
+ key,
175
+ subtitle: texts.text3,
176
+ campaignType: service.discounts?.[0]?.campaign_type,
177
+ discountType: discount.type,
178
+ amount: roundedAmount,
179
+ discountLabel: texts.text2,
180
+ matchedServices: [service],
181
+ isWowDeal: false,
182
+ campaignImageUrl: imageUrl,
183
+ campaignTexts: texts,
184
+ });
185
+ }
186
+ continue;
187
+ }
188
+
134
189
  const campaignType = service.discounts?.[0]?.campaign_type;
135
190
  const campaignLabel = campaignType
136
191
  ? CAMPAIGN_TYPE_LABELS[campaignType]
@@ -142,7 +197,6 @@ export function deriveDiscountCards(
142
197
  ? slugify(service.offer_text)
143
198
  : DESCUENTOS_ESPECIALES_KEY;
144
199
  const subtitle = campaignLabel || DESCUENTOS_ESPECIALES_LABEL;
145
- const roundedAmount = Math.round(discount.amount);
146
200
  const key = `${category}_${discount.type}_${roundedAmount}`;
147
201
 
148
202
  const existing = cards.get(key);
@@ -10,7 +10,12 @@ export interface DiscountBannerServiceItem {
10
10
  discount_type?: string;
11
11
  discount_value?: number;
12
12
  max_discount?: number;
13
- discounts?: Array<{ new_ui_enabled?: boolean; campaign_type?: string }>;
13
+ discounts?: Array<{
14
+ new_ui_enabled?: boolean;
15
+ campaign_type?: string;
16
+ campaign_image_url?: string;
17
+ campaign_text?: { text_1?: string; text_2?: string; text_3?: string };
18
+ }>;
14
19
  [key: string]: any;
15
20
  }
16
21
 
@@ -31,6 +36,9 @@ export interface DiscountBannerCard {
31
36
  discountLabel: string;
32
37
  matchedServices: DiscountBannerServiceItem[];
33
38
  isWowDeal: boolean;
39
+ // Present when the card's art and copy come from the backend
40
+ campaignImageUrl?: string;
41
+ campaignTexts?: { text1: string; text2: string; text3: string };
34
42
  }
35
43
 
36
44
  export interface DiscountBannersProps {
@@ -16,12 +16,13 @@ const commonService = {
16
16
  return sign + formattedAmount;
17
17
  },
18
18
 
19
- currencyExact: (amount) => {
20
- if (amount === null || amount === undefined || isNaN(amount)) return "S/0";
19
+ currencyExact: (amount: number, currencySign?: string) => {
20
+ const sign = currencySign || "$";
21
+ if (amount === null || amount === undefined || isNaN(amount)) return sign + "0";
21
22
  const rounded = Math.round(+amount * 100) / 100;
22
23
  const parts = String(rounded).split(".");
23
24
  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ".");
24
- return "S/" + parts.join(".");
25
+ return sign + parts.join(".");
25
26
  },
26
27
 
27
28
  currencyFixed(amount: number, currencySign?: string, decimals: number = 2) {