kupos-ui-components-lib 10.4.67 → 10.4.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/components/DiscountBanners/DiscountBannerDesktop.js +74 -12
  2. package/dist/components/DiscountBanners/DiscountBannerMobile.js +73 -12
  3. package/dist/components/DiscountBanners/types.d.ts +5 -0
  4. package/dist/components/ServiceItem/ServiceItemDesktop.d.ts +1 -1
  5. package/dist/components/ServiceItem/ServiceItemDesktop.js +2 -2
  6. package/dist/components/ServiceItem/ServiceItemMobile.d.ts +1 -1
  7. package/dist/components/ServiceItem/ServiceItemMobile.js +2 -2
  8. package/dist/components/ServiceItem/mobileTypes.d.ts +1 -0
  9. package/dist/components/ServiceItem/types.d.ts +1 -0
  10. package/dist/styles.css +4 -0
  11. package/dist/ui/DiscountBanners/DiscountBanners.js +72 -11
  12. package/dist/ui/DiscountBanners/discountUtils.js +41 -2
  13. package/dist/ui/DiscountBanners/types.d.ts +5 -0
  14. package/dist/ui/SeatSection/SeatSection.d.ts +2 -1
  15. package/dist/ui/SeatSection/SeatSection.js +13 -5
  16. package/dist/ui/mobileweb/DateTimeSectionMobile.d.ts +2 -1
  17. package/dist/ui/mobileweb/DateTimeSectionMobile.js +2 -2
  18. package/dist/ui/mobileweb/SeatSectionMobile.d.ts +2 -1
  19. package/dist/ui/mobileweb/SeatSectionMobile.js +16 -3
  20. package/package.json +1 -1
  21. package/src/components/DiscountBanners/DiscountBannerDesktop.tsx +94 -6
  22. package/src/components/DiscountBanners/DiscountBannerMobile.tsx +93 -6
  23. package/src/components/DiscountBanners/types.ts +4 -0
  24. package/src/components/ServiceItem/ServiceItemDesktop.tsx +2 -0
  25. package/src/components/ServiceItem/ServiceItemMobile.tsx +2 -0
  26. package/src/components/ServiceItem/mobileTypes.ts +2 -0
  27. package/src/components/ServiceItem/types.ts +2 -0
  28. package/src/ui/DiscountBanners/DiscountBanners.tsx +93 -6
  29. package/src/ui/DiscountBanners/discountUtils.ts +53 -5
  30. package/src/ui/DiscountBanners/types.ts +4 -0
  31. package/src/ui/SeatSection/SeatSection.tsx +19 -4
  32. package/src/ui/mobileweb/DateTimeSectionMobile.tsx +3 -0
  33. package/src/ui/mobileweb/SeatSectionMobile.tsx +24 -9
@@ -37,6 +37,55 @@ const SIZE_CLASSES = {
37
37
  },
38
38
  };
39
39
 
40
+ // Rows with logos scale every card up 20%, keeping the art's 5:2 ratio so the pill baked
41
+ // into it doesn't distort. Inline so the sizes don't depend on the host's stylesheet.
42
+ const OPERATOR_LOGO_LAYOUT = {
43
+ default: {
44
+ card: { width: "240px", height: "96px", paddingTop: "10px" },
45
+ chip: {
46
+ width: "97px",
47
+ height: "20px",
48
+ marginLeft: "5px",
49
+ fontSize: "10px",
50
+ lineHeight: "20px",
51
+ },
52
+ flameBadge: { fontSize: "22px", lineHeight: "19px" },
53
+ flamePercent: { fontSize: "12px" },
54
+ discount: { lineHeight: "16px" },
55
+ subtitle: {
56
+ fontSize: "11px",
57
+ lineHeight: "11px",
58
+ marginTop: 0,
59
+ maxWidth: "100px",
60
+ },
61
+ row: { gap: "3px", fontSize: "10px", marginTop: "4px", maxWidth: "128px" },
62
+ logo: { height: "18px", width: "84px", minWidth: 0 },
63
+ },
64
+ compact: {
65
+ card: { width: "204px", height: "82px", paddingTop: "9px" },
66
+ chip: {
67
+ width: "83px",
68
+ height: "18px",
69
+ marginLeft: "5px",
70
+ fontSize: "9px",
71
+ lineHeight: "18px",
72
+ },
73
+ flameBadge: { fontSize: "16px", lineHeight: "16px" },
74
+ flamePercent: { fontSize: "10px" },
75
+ discount: { lineHeight: "14px" },
76
+ subtitle: {
77
+ fontSize: "9.5px",
78
+ lineHeight: "9.5px",
79
+ marginTop: 0,
80
+ maxWidth: "85px",
81
+ },
82
+ row: { gap: "2px", fontSize: "8px", marginTop: "3px", maxWidth: "108px" },
83
+ logo: { height: "14px", width: "70px", minWidth: 0 },
84
+ },
85
+ };
86
+
87
+ const MAX_VISIBLE_OPERATOR_LOGOS = 3;
88
+
40
89
  const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
41
90
 
42
91
  const REMATE_KEY = "remate";
@@ -121,6 +170,8 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
121
170
  const defaultArt = resolveArt(cards);
122
171
  const chipPillInArt = artIncludesChipPill ?? usingDefaultArt;
123
172
  const sizeClasses = SIZE_CLASSES[size];
173
+ const hasLogoRow = cards.some((card) => card.operatorLogos?.length);
174
+ const logoLayout = OPERATOR_LOGO_LAYOUT[size ?? "default"];
124
175
 
125
176
  return (
126
177
  <div
@@ -129,6 +180,13 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
129
180
  >
130
181
  {cards.map((card, index) => {
131
182
  const isActive = activeKey === card.key;
183
+ const operatorLogos = card.operatorLogos ?? [];
184
+ const hasLogos = operatorLogos.length > 0;
185
+ // Three logos fill the space beside the art, so a "+N" takes the third slot
186
+ const visibleLogos =
187
+ operatorLogos.length > MAX_VISIBLE_OPERATOR_LOGOS
188
+ ? operatorLogos.slice(0, MAX_VISIBLE_OPERATOR_LOGOS - 1)
189
+ : operatorLogos;
132
190
  const isDimmed = activeKey != null && !isActive;
133
191
  const backgroundImage = card.campaignImageUrl
134
192
  ? card.campaignImageUrl
@@ -162,15 +220,17 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
162
220
  ? `url(${backgroundImage})`
163
221
  : undefined,
164
222
  backgroundSize: "100% 100%",
223
+ ...(hasLogoRow ? logoLayout.card : undefined),
165
224
  }}
166
225
  >
167
226
  <span
168
227
  className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
169
- style={
170
- chipPillInArt || card.campaignImageUrl
228
+ style={{
229
+ ...(chipPillInArt || card.campaignImageUrl
171
230
  ? undefined
172
- : { backgroundColor: "rgba(255,255,255,0.55)" }
173
- }
231
+ : { backgroundColor: "rgba(255,255,255,0.55)" }),
232
+ ...(hasLogoRow ? logoLayout.chip : undefined),
233
+ }}
174
234
  >
175
235
  {card.campaignTexts ? card.campaignTexts.text1 : label}
176
236
  </span>
@@ -183,12 +243,16 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
183
243
  textShadow: "0 1px 2px rgba(0,0,0,0.28)",
184
244
  fontWeight: 900,
185
245
  whiteSpace: "nowrap",
246
+ ...(hasLogoRow ? logoLayout.flameBadge : undefined),
186
247
  }}
187
248
  >
188
249
  {card.amount}
189
250
  <span
190
251
  className={sizeClasses.flamePercent}
191
- style={{ fontWeight: 700 }}
252
+ style={{
253
+ fontWeight: 700,
254
+ ...(hasLogoRow ? logoLayout.flamePercent : undefined),
255
+ }}
192
256
  >
193
257
  %
194
258
  </span>
@@ -197,16 +261,39 @@ const DiscountBanners: React.FC<DiscountBannersProps> = ({
197
261
  <span className={`flex flex-col ${sizeClasses.column}`}>
198
262
  <span
199
263
  className={`bold-text text-[#464647] ${sizeClasses.discount}`}
264
+ style={hasLogos ? logoLayout.discount : undefined}
200
265
  >
201
266
  {card.discountLabel}
202
267
  </span>
203
268
  <span
204
269
  className={`bold-text ${sizeClasses.subtitle}`}
205
- style={{ color: accentColor }}
270
+ style={{
271
+ color: accentColor,
272
+ ...(hasLogos ? logoLayout.subtitle : undefined),
273
+ }}
206
274
  >
207
275
  {card.subtitle}
208
276
  </span>
209
277
  </span>
278
+ {hasLogos && (
279
+ <span
280
+ className="bold-text flex items-center whitespace-nowrap text-[#464647]"
281
+ style={logoLayout.row}
282
+ >
283
+ {visibleLogos.map((logo) => (
284
+ <img
285
+ key={logo.url}
286
+ src={logo.url}
287
+ alt={logo.name ?? ""}
288
+ className="block object-contain object-left"
289
+ style={logoLayout.logo}
290
+ />
291
+ ))}
292
+ {visibleLogos.length < operatorLogos.length && (
293
+ <span>+{operatorLogos.length - visibleLogos.length}</span>
294
+ )}
295
+ </span>
296
+ )}
210
297
  </button>
211
298
  );
212
299
  })}
@@ -8,6 +8,8 @@ import {
8
8
  const DESCUENTOS_ESPECIALES_KEY = "descuentos_especiales";
9
9
  const DESCUENTOS_ESPECIALES_LABEL = "Descuentos especiales";
10
10
  const REMATE_KEY = "remate";
11
+ const OPERATOR_LOGO_MIN_PERCENT = 30;
12
+ const OPERATOR_LOGO_CAMPAIGN_TYPE = "operator_logo";
11
13
 
12
14
  export const CAMPAIGN_TYPE_LABELS: Record<string, string> = {
13
15
  last_minute: "Oferta de último minuto",
@@ -112,6 +114,40 @@ function getServiceDiscount(
112
114
  return null;
113
115
  }
114
116
 
117
+ // The check uses the rounded amount so it agrees with the percent printed on the card.
118
+ function meetsLogoThreshold(type: ServiceDiscount["type"], amount: number) {
119
+ return type === "percentage" && Math.round(amount) >= OPERATOR_LOGO_MIN_PERCENT;
120
+ }
121
+
122
+ function showsOperatorLogo(
123
+ service: DiscountBannerServiceItem,
124
+ discount: ServiceDiscount,
125
+ ): boolean {
126
+ const campaign = service.discounts?.[0];
127
+ return (
128
+ !!campaign?.operator_logo ||
129
+ campaign?.campaign_type === OPERATOR_LOGO_CAMPAIGN_TYPE ||
130
+ meetsLogoThreshold(discount.type, discount.amount)
131
+ );
132
+ }
133
+
134
+ // Wow-deal services send operator_logo_url/operator_name; regular services send
135
+ // operator_details. A logo sent on the campaign wins over both.
136
+ function addOperatorLogo(
137
+ card: DiscountBannerCard,
138
+ service: DiscountBannerServiceItem,
139
+ ) {
140
+ const url =
141
+ service.discounts?.[0]?.operator_logo ||
142
+ service.operator_logo_url ||
143
+ service.operator_details?.[0];
144
+ if (!url || card.operatorLogos?.some((logo) => logo.url === url)) return;
145
+ card.operatorLogos = [
146
+ ...(card.operatorLogos ?? []),
147
+ { url, name: service.operator_name || service.operator_details?.[2] },
148
+ ];
149
+ }
150
+
115
151
  function toDiscountLabel(
116
152
  discount: ServiceDiscount,
117
153
  currencySign: string,
@@ -150,6 +186,11 @@ export function deriveDiscountCards(
150
186
  matchedServices: Array.isArray(wowServices) ? wowServices : [],
151
187
  isWowDeal: true,
152
188
  });
189
+ if (meetsLogoThreshold(remateDiscount.type, remateDiscount.amount)) {
190
+ for (const service of cards.get(REMATE_KEY)!.matchedServices) {
191
+ addOperatorLogo(cards.get(REMATE_KEY)!, service);
192
+ }
193
+ }
153
194
  }
154
195
 
155
196
  for (const service of services || []) {
@@ -158,6 +199,8 @@ export function deriveDiscountCards(
158
199
 
159
200
  const roundedAmount = Math.round(discount.amount);
160
201
 
202
+ const showsLogo = showsOperatorLogo(service, discount);
203
+
161
204
  // Backend-driven cards: art and text come from the service; services with the
162
205
  // same discount (and same campaign content) are grouped into one card.
163
206
  const backendCampaign = discount.isDp ? null : getBackendCampaign(service);
@@ -183,6 +226,7 @@ export function deriveDiscountCards(
183
226
  campaignTexts: texts,
184
227
  });
185
228
  }
229
+ if (showsLogo) addOperatorLogo(cards.get(key)!, service);
186
230
  continue;
187
231
  }
188
232
 
@@ -191,11 +235,14 @@ export function deriveDiscountCards(
191
235
  ? CAMPAIGN_TYPE_LABELS[campaignType]
192
236
  : undefined;
193
237
 
194
- const category = campaignType
195
- ? slugify(campaignType)
196
- : service.offer_text
197
- ? slugify(service.offer_text)
198
- : DESCUENTOS_ESPECIALES_KEY;
238
+ // Logo cards group purely by discount, so every operator offering the same percent
239
+ // lands on one banner even when their offer_text differs.
240
+ const category =
241
+ campaignType && campaignType !== OPERATOR_LOGO_CAMPAIGN_TYPE
242
+ ? slugify(campaignType)
243
+ : service.offer_text && !showsLogo
244
+ ? slugify(service.offer_text)
245
+ : DESCUENTOS_ESPECIALES_KEY;
199
246
  const subtitle = campaignLabel || DESCUENTOS_ESPECIALES_LABEL;
200
247
  const key = `${category}_${discount.type}_${roundedAmount}`;
201
248
 
@@ -217,6 +264,7 @@ export function deriveDiscountCards(
217
264
  isWowDeal: false,
218
265
  });
219
266
  }
267
+ if (showsLogo) addOperatorLogo(cards.get(key)!, service);
220
268
  }
221
269
 
222
270
  return Array.from(cards.values());
@@ -13,6 +13,7 @@ export interface DiscountBannerServiceItem {
13
13
  discounts?: Array<{
14
14
  new_ui_enabled?: boolean;
15
15
  campaign_type?: string;
16
+ operator_logo?: string;
16
17
  campaign_image_url?: string;
17
18
  campaign_text?: { text_1?: string; text_2?: string; text_3?: string };
18
19
  }>;
@@ -39,6 +40,9 @@ export interface DiscountBannerCard {
39
40
  // Present when the card's art and copy come from the backend
40
41
  campaignImageUrl?: string;
41
42
  campaignTexts?: { text1: string; text2: string; text3: string };
43
+ // One entry per operator on the card; set on percentage cards of 30% or more, or when
44
+ // the campaign asks for a logo
45
+ operatorLogos?: Array<{ url: string; name?: string }>;
42
46
  }
43
47
 
44
48
  export interface DiscountBannersProps {
@@ -22,6 +22,7 @@ interface SeatSectionProps {
22
22
  isPeru?: boolean;
23
23
  showDesdeLabel?: boolean;
24
24
  showSeatsWithDiscount?: boolean;
25
+ showSeatSoldOutLabel?: boolean;
25
26
  serviceItem?: any;
26
27
  renderIcon?: (iconKey: string, size?: string) => React.ReactNode;
27
28
  discountSeatPriceColor?: string;
@@ -174,6 +175,7 @@ function SeatSection({
174
175
  isShowSeatIcon,
175
176
  showDesdeLabel = false,
176
177
  showSeatsWithDiscount = false,
178
+ showSeatSoldOutLabel = false,
177
179
  }: SeatSectionProps): React.ReactElement {
178
180
  const uniqueSeats = getUniqueSeats(seatTypes, isTrain);
179
181
  const sortedSeatTypes = getSortedSeatTypes(seatTypes, isTrain);
@@ -193,6 +195,19 @@ function SeatSection({
193
195
  ? CommonService.currency(0, currencySign)
194
196
  : CommonService.currency(price, currencySign);
195
197
 
198
+ // sold_out is keyed by seat type key, e.g. { "140": false, "160": true }
199
+ const isSeatTypeSoldOut = (seat: any) =>
200
+ showSeatSoldOutLabel &&
201
+ (serviceItem?.sold_out?.[seat?.key] === true ||
202
+ serviceItem?.sold_out?.[seat?.label] === true);
203
+
204
+ const renderSeatPrice = (seat: any, price: number) =>
205
+ isSeatTypeSoldOut(seat) ? (
206
+ <span style={{ color: priceColor }}>Agotado</span>
207
+ ) : (
208
+ formatPrice(price)
209
+ );
210
+
196
211
  const renderSeatNames = () => {
197
212
  const seats = removeDuplicateSeats ? uniqueSeats : sortedSeatTypes;
198
213
 
@@ -287,7 +302,7 @@ function SeatSection({
287
302
  key={key}
288
303
  className="flex items-center text-[13.33px] bold-text"
289
304
  >
290
- {formatPrice(discountedPrice)}
305
+ {renderSeatPrice(val, discountedPrice)}
291
306
  </span>
292
307
  );
293
308
  });
@@ -304,7 +319,7 @@ function SeatSection({
304
319
  return (
305
320
  <>
306
321
  <span className="flex items-center text-[13.33px] bold-text">
307
- {formatPrice(discountedPrice)}
322
+ {renderSeatPrice(allSeats[0], discountedPrice)}
308
323
  </span>
309
324
  </>
310
325
  );
@@ -316,7 +331,7 @@ function SeatSection({
316
331
  val.price,
317
332
  serviceItem,
318
333
  );
319
- return <span key={key}>{formatPrice(discountedPrice)}</span>;
334
+ return <span key={key}>{renderSeatPrice(val, discountedPrice)}</span>;
320
335
  });
321
336
  }
322
337
 
@@ -332,7 +347,7 @@ function SeatSection({
332
347
  <span key={key} className="flex items-center text-[13.33px] bold-text">
333
348
  {typeof discountedPrice === "string" ||
334
349
  typeof discountedPrice === "number"
335
- ? formatPrice(discountedPrice)
350
+ ? renderSeatPrice(val, discountedPrice)
336
351
  : null}
337
352
  </span>
338
353
  );
@@ -29,6 +29,7 @@ interface DateTimeSectionMobileProps {
29
29
  discountSeatCrossPriceColor?: string;
30
30
  isShowSeatIcon?: boolean;
31
31
  showDesdeLabel?: boolean;
32
+ showSeatSoldOutLabel?: boolean;
32
33
  }
33
34
 
34
35
  const pad = (n: number) => (n < 10 ? "0" + n : String(n));
@@ -151,6 +152,7 @@ function DateTimeSectionMobile({
151
152
  discountSeatCrossPriceColor,
152
153
  isShowSeatIcon,
153
154
  showDesdeLabel,
155
+ showSeatSoldOutLabel,
154
156
  }: DateTimeSectionMobileProps): React.ReactElement {
155
157
  const { cleaned: cleanedDepTime, hasAM, hasPM } = getCleanedDepTime(depTime);
156
158
 
@@ -234,6 +236,7 @@ function DateTimeSectionMobile({
234
236
  discountSeatCrossPriceColor={discountSeatCrossPriceColor}
235
237
  isShowSeatIcon={isShowSeatIcon}
236
238
  showDesdeLabel={showDesdeLabel}
239
+ showSeatSoldOutLabel={showSeatSoldOutLabel}
237
240
  />
238
241
  </div>
239
242
  );
@@ -37,6 +37,8 @@ interface SeatSectionMobileProps {
37
37
  isShowSeatIcon?: boolean;
38
38
  // shows "Desde" above the price on every service, not only inclination ones
39
39
  showDesdeLabel?: boolean;
40
+ // shows "Agotado" in place of the price for sold-out seat types
41
+ showSeatSoldOutLabel?: boolean;
40
42
  }
41
43
 
42
44
  interface SeatRowProps {
@@ -155,6 +157,7 @@ function SeatSectionMobile({
155
157
  discountSeatCrossPriceColor,
156
158
  isShowSeatIcon,
157
159
  showDesdeLabel = false,
160
+ showSeatSoldOutLabel = false,
158
161
  }: SeatSectionMobileProps): React.ReactElement {
159
162
  const hasMultipleTypes = (seatTypesData?.length ?? 0) > 2;
160
163
 
@@ -186,6 +189,12 @@ function SeatSectionMobile({
186
189
  return commonService.currency(discountedPrice, currencySign);
187
190
  };
188
191
 
192
+ // sold_out is keyed by seat type key, e.g. { "140": false, "160": true }
193
+ const isSeatTypeSoldOut = (seat: any) =>
194
+ showSeatSoldOutLabel &&
195
+ (serviceItem?.sold_out?.[seat?.key] === true ||
196
+ serviceItem?.sold_out?.[seat?.label] === true);
197
+
189
198
  const getLowestFare = (): number | null => {
190
199
  const filtered = seatTypesData
191
200
  ?.filter((item) => getFilteredSeats(item.label))
@@ -256,10 +265,12 @@ function SeatSectionMobile({
256
265
  className="min-[420]:text-[13px] text-[12px] bold-text"
257
266
  style={{ color: priceColor }}
258
267
  >
259
- {commonService.currency(
260
- showOriginalFare ? originalPrice : discountedPrice,
261
- currencySign,
262
- )}
268
+ {isSeatTypeSoldOut(seat)
269
+ ? "Agotado"
270
+ : commonService.currency(
271
+ showOriginalFare ? originalPrice : discountedPrice,
272
+ currencySign,
273
+ )}
263
274
  </span>
264
275
  </div>
265
276
  );
@@ -333,12 +344,16 @@ function SeatSectionMobile({
333
344
  </span>
334
345
  <span
335
346
  className="min-[420]:text-[13px] text-[12px] bold-text"
336
- style={{ color: priceColor }}
347
+ style={{
348
+ color: priceColor,
349
+ }}
337
350
  >
338
- {commonService.currency(
339
- showOriginalFare ? originalPrice : discountedPrice,
340
- currencySign,
341
- )}
351
+ {isSeatTypeSoldOut(uniqueSeats[0])
352
+ ? "Agotado"
353
+ : commonService.currency(
354
+ showOriginalFare ? originalPrice : discountedPrice,
355
+ currencySign,
356
+ )}
342
357
  </span>
343
358
  </div>
344
359
  </>