kupos-ui-components-lib 10.4.31 → 10.4.34

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 (71) hide show
  1. package/dist/KuposUIComponent.d.ts +1 -1
  2. package/dist/KuposUIComponent.js +3 -0
  3. package/dist/assets/images/discount/blue.png +0 -0
  4. package/dist/assets/images/discount/cash.png +0 -0
  5. package/dist/assets/images/discount/first.png +0 -0
  6. package/dist/assets/images/discount/green.png +0 -0
  7. package/dist/assets/images/discount/instant.png +0 -0
  8. package/dist/assets/images/discount/last.png +0 -0
  9. package/dist/assets/images/discount/promo.png +0 -0
  10. package/dist/assets/images/discount/round.png +0 -0
  11. package/dist/assets/images/discount/seat.png +0 -0
  12. package/dist/assets/images/discount/yellow.png +0 -0
  13. package/dist/assets/images/index.d.ts +12 -0
  14. package/dist/assets/images/index.js +12 -0
  15. package/dist/assets/images/index.ts +14 -0
  16. package/dist/components/DiscountBanners/DiscountBannerDesktop.d.ts +11 -0
  17. package/dist/components/DiscountBanners/DiscountBannerDesktop.js +133 -0
  18. package/dist/components/DiscountBanners/DiscountBannerMobile.d.ts +11 -0
  19. package/dist/components/DiscountBanners/DiscountBannerMobile.js +132 -0
  20. package/dist/components/DiscountBanners/ResponsiveDiscountBanner.d.ts +3 -0
  21. package/dist/components/DiscountBanners/ResponsiveDiscountBanner.js +10 -0
  22. package/dist/components/DiscountBanners/index.d.ts +5 -0
  23. package/dist/components/DiscountBanners/index.js +4 -0
  24. package/dist/components/DiscountBanners/types.d.ts +51 -0
  25. package/dist/components/DiscountBanners/types.js +1 -0
  26. package/dist/components/ServiceItem/ServiceItemDesktop.js +1 -1
  27. package/dist/components/ServiceItem/ServiceItemMobile.js +0 -1
  28. package/dist/index.d.ts +5 -2
  29. package/dist/index.js +6 -1
  30. package/dist/styles.css +89 -0
  31. package/dist/ui/DiscountBanners/DiscountBanners.d.ts +4 -0
  32. package/dist/ui/DiscountBanners/DiscountBanners.js +114 -0
  33. package/dist/ui/DiscountBanners/defaultBackgrounds.d.ts +12 -0
  34. package/dist/ui/DiscountBanners/defaultBackgrounds.js +38 -0
  35. package/dist/ui/DiscountBanners/discountUtils.d.ts +3 -0
  36. package/dist/ui/DiscountBanners/discountUtils.js +126 -0
  37. package/dist/ui/DiscountBanners/index.d.ts +5 -0
  38. package/dist/ui/DiscountBanners/index.js +3 -0
  39. package/dist/ui/DiscountBanners/types.d.ts +49 -0
  40. package/dist/ui/DiscountBanners/types.js +1 -0
  41. package/dist/ui/ServiceBadges/ServiceBadges.js +1 -1
  42. package/dist/ui/mobileweb/ServiceBadgesMobile.js +1 -1
  43. package/package.json +21 -6
  44. package/src/KuposUIComponent.tsx +13 -1
  45. package/src/assets/images/discount/blue.png +0 -0
  46. package/src/assets/images/discount/cash.png +0 -0
  47. package/src/assets/images/discount/first.png +0 -0
  48. package/src/assets/images/discount/green.png +0 -0
  49. package/src/assets/images/discount/instant.png +0 -0
  50. package/src/assets/images/discount/last.png +0 -0
  51. package/src/assets/images/discount/promo.png +0 -0
  52. package/src/assets/images/discount/round.png +0 -0
  53. package/src/assets/images/discount/seat.png +0 -0
  54. package/src/assets/images/discount/yellow.png +0 -0
  55. package/src/assets/images/index.ts +14 -0
  56. package/src/components/DiscountBanners/DiscountBannerDesktop.tsx +240 -0
  57. package/src/components/DiscountBanners/DiscountBannerMobile.tsx +234 -0
  58. package/src/components/DiscountBanners/ResponsiveDiscountBanner.tsx +16 -0
  59. package/src/components/DiscountBanners/index.ts +16 -0
  60. package/src/components/DiscountBanners/types.ts +55 -0
  61. package/src/components/ServiceItem/ServiceItemDesktop.tsx +1 -0
  62. package/src/components/ServiceItem/ServiceItemMobile.tsx +0 -1
  63. package/src/index.ts +34 -3
  64. package/src/types/images.d.ts +12 -0
  65. package/src/ui/DiscountBanners/DiscountBanners.tsx +215 -0
  66. package/src/ui/DiscountBanners/defaultBackgrounds.ts +42 -0
  67. package/src/ui/DiscountBanners/discountUtils.ts +166 -0
  68. package/src/ui/DiscountBanners/index.ts +14 -0
  69. package/src/ui/DiscountBanners/types.ts +53 -0
  70. package/src/ui/ServiceBadges/ServiceBadges.tsx +1 -1
  71. package/src/ui/mobileweb/ServiceBadgesMobile.tsx +1 -1
package/src/index.ts CHANGED
@@ -25,6 +25,13 @@ import {
25
25
  ResponsiveFilterBar,
26
26
  } from "./components/FilterBar";
27
27
 
28
+ //Import Discount Banner's
29
+ import {
30
+ DiscountBannerDesktop,
31
+ DiscountBannerMobile,
32
+ ResponsiveDiscountBanner,
33
+ } from "./components/DiscountBanners";
34
+
28
35
  // Import FilterSortBar components
29
36
  import { FilterSortBarMobile } from "./components/FilterSortBar";
30
37
 
@@ -38,6 +45,9 @@ import {
38
45
  // Import Modal components
39
46
  import { Modal, ModalHeader } from "./ui/Modal";
40
47
 
48
+ // Import DiscountBanners components
49
+ import { DiscountBanners } from "./ui/DiscountBanners";
50
+
41
51
  export {
42
52
  ServiceItemDesktop,
43
53
  ServiceItemMobile,
@@ -63,7 +73,6 @@ export {
63
73
  // FilterSortBar components
64
74
  FilterSortBarMobile,
65
75
 
66
-
67
76
  //Survey components
68
77
  SurveyDesktop,
69
78
  SurveyMobile,
@@ -72,6 +81,11 @@ export {
72
81
  // Modal components
73
82
  Modal,
74
83
  ModalHeader,
84
+
85
+ // DiscountBanners components
86
+ DiscountBannerMobile,
87
+ DiscountBannerDesktop,
88
+ ResponsiveDiscountBanner,
75
89
  };
76
90
 
77
91
  // Also export types
@@ -95,5 +109,22 @@ export type {
95
109
  } from "./components/FilterSortBar";
96
110
  export { DEFAULT_CATEGORY_LABELS } from "./components/FilterSortBar";
97
111
  export type { SurveyProps } from "./components/Survey/types";
98
- export type { ModalProps, ModalVariant, ModalSize, ModalHeaderProps } from "./ui/Modal";
99
-
112
+ export type {
113
+ ModalProps,
114
+ ModalVariant,
115
+ ModalSize,
116
+ ModalHeaderProps,
117
+ } from "./ui/Modal";
118
+ export {
119
+ deriveDiscountCards,
120
+ CAMPAIGN_TYPE_LABELS,
121
+ CAMPAIGN_ART,
122
+ DEFAULT_BACKGROUND_IMAGES,
123
+ toImageUrl,
124
+ } from "./ui/DiscountBanners";
125
+ export type {
126
+ DiscountBannerCard,
127
+ DiscountBannerServiceItem,
128
+ DiscountBannersProps,
129
+ WowDealData,
130
+ } from "./components/DiscountBanners";
@@ -0,0 +1,12 @@
1
+ // This package builds with plain `tsc`, which has no image loader, so the .png
2
+ // imports in the components are left in the emitted JS as-is and resolved by the
3
+ // CONSUMER's bundler. copy-assets.js mirrors src/assets into dist/assets, so the
4
+ // relative specifiers still point at a real file from inside dist.
5
+ //
6
+ // Bundlers disagree on what the import evaluates to: webpack asset modules and
7
+ // Vite hand back a URL string, while Next's image loader hands back a
8
+ // StaticImageData object. The union covers both; use toImageUrl() to read it.
9
+ declare module "*.png" {
10
+ const src: string | { src: string };
11
+ export default src;
12
+ }
@@ -0,0 +1,215 @@
1
+ "use client";
2
+
3
+ import React, { useMemo, useState } from "react";
4
+ import { DiscountBannerCard, DiscountBannersProps } from "./types";
5
+ import { deriveDiscountCards } from "./discountUtils";
6
+ import {
7
+ CAMPAIGN_ART,
8
+ DEFAULT_BACKGROUND_IMAGES,
9
+ toImageUrl,
10
+ } from "./defaultBackgrounds";
11
+
12
+ const DEFAULT_LABEL = "Oferta especial";
13
+ const DEFAULT_ACCENT_PALETTE = [
14
+ "#107E23", // green (remate)
15
+ "#FFC000", // orange (primera compra)
16
+ "#0069D1", // blue (descuentos especiales)
17
+ ];
18
+
19
+ const SIZE_CLASSES = {
20
+ default: {
21
+ card: "w-[200px] h-[80px] pt-[8px] pb-0 pl-[10px] pr-[8px]",
22
+ chip: "w-[81px] h-[17px] text-[9px] leading-[17px]",
23
+ column: "max-w-[116px] mt-[3px]",
24
+ discount: "text-[15px] leading-[17px]",
25
+ subtitle: "text-[12px] leading-[14px] mt-[1px]",
26
+ flameBadge: "text-[18px] leading-[16px]",
27
+ flamePercent: "text-[10px]",
28
+ },
29
+ compact: {
30
+ card: "w-[170px] h-[68px] pt-[7px] pb-0 pl-[8px] pr-[7px]",
31
+ chip: "w-[69px] h-[15px] text-[8px] leading-[15px]",
32
+ column: "max-w-[99px] mt-[2px]",
33
+ discount: "text-[13px] leading-[15px]",
34
+ subtitle: "text-[10.5px] leading-[12px] mt-[1px]",
35
+ flameBadge: "text-[13.5px] leading-[13.5px]",
36
+ flamePercent: "text-[8px]",
37
+ },
38
+ };
39
+
40
+ const FLAME_CENTER = { left: "76.8%", top: "56.4%" };
41
+
42
+ const REMATE_KEY = "remate";
43
+
44
+ function isRemateCard(card: DiscountBannerCard): boolean {
45
+ return card.isWowDeal || card.key === REMATE_KEY;
46
+ }
47
+
48
+ // Remate always takes the green flame. A campaign with its own art (CAMPAIGN_ART)
49
+ // uses it. Anything left over — return_booking and early_bird, which have no art of
50
+ // their own, plus unrecognised campaign types — alternates yellow, blue, yellow...
51
+ // The ordinal counts only those leftovers, so the alternation is unaffected by how
52
+ // many campaign-specific cards sit between them.
53
+ type CardArt = { image: string; accent: string };
54
+
55
+ const FALLBACK_ART: CardArt[] = [
56
+ { image: DEFAULT_BACKGROUND_IMAGES[1], accent: "#FFC000" },
57
+ { image: DEFAULT_BACKGROUND_IMAGES[2], accent: "#0069D1" },
58
+ ];
59
+ const REMATE_ART: CardArt = {
60
+ image: DEFAULT_BACKGROUND_IMAGES[0],
61
+ accent: "#107E23",
62
+ };
63
+
64
+ function resolveArt(cards: DiscountBannerCard[]): CardArt[] {
65
+ let ordinal = 0;
66
+ return cards.map((card) => {
67
+ if (isRemateCard(card)) return REMATE_ART;
68
+ const campaignArt = card.campaignType
69
+ ? CAMPAIGN_ART[card.campaignType]
70
+ : undefined;
71
+ if (campaignArt) return campaignArt;
72
+ const art = FALLBACK_ART[ordinal % FALLBACK_ART.length];
73
+ ordinal += 1;
74
+ return art;
75
+ });
76
+ }
77
+
78
+ const DiscountBanners: React.FC<DiscountBannersProps> = ({
79
+ services = [],
80
+ wowDealData,
81
+ backgroundImages = [],
82
+ artIncludesChipPill,
83
+ accentColors = [],
84
+ label = DEFAULT_LABEL,
85
+ currencySign = "$",
86
+ size = "default",
87
+ selectedKey,
88
+ defaultSelectedKey = null,
89
+ onDiscountSelect,
90
+ className = "",
91
+ style,
92
+ }) => {
93
+ const cards = useMemo(
94
+ () => deriveDiscountCards(services, wowDealData, currencySign),
95
+ [services, wowDealData, currencySign],
96
+ );
97
+
98
+ const [internalSelectedKey, setInternalSelectedKey] = useState<
99
+ string | null
100
+ >(defaultSelectedKey);
101
+
102
+ const isControlled = selectedKey !== undefined;
103
+ const activeKey = isControlled ? selectedKey : internalSelectedKey;
104
+
105
+ if (!cards.length) return null;
106
+
107
+ const handleCardClick = (card: DiscountBannerCard) => {
108
+ const nextKey = activeKey === card.key ? null : card.key;
109
+
110
+ if (!isControlled) {
111
+ setInternalSelectedKey(nextKey);
112
+ }
113
+
114
+ const selectedCard = nextKey
115
+ ? (cards.find((c) => c.key === nextKey) ?? null)
116
+ : null;
117
+ onDiscountSelect?.(selectedCard, selectedCard?.matchedServices ?? []);
118
+ };
119
+
120
+ const usingDefaultArt = !backgroundImages.length;
121
+ const defaultArt = resolveArt(cards);
122
+ const chipPillInArt = artIncludesChipPill ?? usingDefaultArt;
123
+ const sizeClasses = SIZE_CLASSES[size];
124
+
125
+ return (
126
+ <div
127
+ className={`kupos-discount-banners flex items-stretch gap-[10px] overflow-x-auto [scrollbar-width:none] ${className}`}
128
+ style={style}
129
+ >
130
+ {cards.map((card, index) => {
131
+ const isActive = activeKey === card.key;
132
+ const isDimmed = activeKey != null && !isActive;
133
+ const backgroundImage = usingDefaultArt
134
+ ? defaultArt[index].image
135
+ : toImageUrl(backgroundImages[index % backgroundImages.length]);
136
+ const accentColor = accentColors.length
137
+ ? accentColors[index % accentColors.length]
138
+ : usingDefaultArt
139
+ ? defaultArt[index].accent
140
+ : DEFAULT_ACCENT_PALETTE[index % DEFAULT_ACCENT_PALETTE.length];
141
+
142
+ const isFlameArt = backgroundImage === DEFAULT_BACKGROUND_IMAGES[0];
143
+ const showFlamePercent =
144
+ isRemateCard(card) &&
145
+ isFlameArt &&
146
+ card.discountType === "percentage" &&
147
+ card.amount > 0;
148
+
149
+ return (
150
+ <button
151
+ key={card.key}
152
+ type="button"
153
+ aria-pressed={isActive}
154
+ onClick={() => handleCardClick(card)}
155
+ 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} ${
156
+ isDimmed ? "opacity-[0.65]" : "opacity-100"
157
+ }`}
158
+ style={{
159
+ backgroundImage: backgroundImage
160
+ ? `url(${backgroundImage})`
161
+ : undefined,
162
+ backgroundSize: "100% 100%",
163
+ }}
164
+ >
165
+ <span
166
+ className={`bold-text flex-shrink-0 rounded-full text-center text-[#464647] overflow-hidden whitespace-nowrap ${sizeClasses.chip}`}
167
+ style={
168
+ chipPillInArt
169
+ ? undefined
170
+ : { backgroundColor: "rgba(255,255,255,0.55)" }
171
+ }
172
+ >
173
+ {label}
174
+ </span>
175
+ {showFlamePercent && (
176
+ <span
177
+ className={`bold-text absolute text-[#ffffff] text-center pointer-events-none ${sizeClasses.flameBadge}`}
178
+ style={{
179
+ ...FLAME_CENTER,
180
+ transform: "translate(-50%, -50%)",
181
+ textShadow: "0 1px 2px rgba(0,0,0,0.28)",
182
+ fontWeight: 900,
183
+ whiteSpace: "nowrap",
184
+ }}
185
+ >
186
+ {card.amount}
187
+ <span
188
+ className={sizeClasses.flamePercent}
189
+ style={{ fontWeight: 700 }}
190
+ >
191
+ %
192
+ </span>
193
+ </span>
194
+ )}
195
+ <span className={`flex flex-col ${sizeClasses.column}`}>
196
+ <span
197
+ className={`bold-text text-[#464647] ${sizeClasses.discount}`}
198
+ >
199
+ {card.discountLabel}
200
+ </span>
201
+ <span
202
+ className={`bold-text ${sizeClasses.subtitle}`}
203
+ style={{ color: accentColor }}
204
+ >
205
+ {card.subtitle}
206
+ </span>
207
+ </span>
208
+ </button>
209
+ );
210
+ })}
211
+ </div>
212
+ );
213
+ };
214
+
215
+ export default DiscountBanners;
@@ -0,0 +1,42 @@
1
+ import greenArt from "../../assets/images/discount/green.png";
2
+ import yellowArt from "../../assets/images/discount/yellow.png";
3
+ import blueArt from "../../assets/images/discount/blue.png";
4
+ import lastMinuteArt from "../../assets/images/discount/last.png";
5
+ import firstTimeArt from "../../assets/images/discount/first.png";
6
+ import cashbackArt from "../../assets/images/discount/cash.png";
7
+ import roundTripArt from "../../assets/images/discount/round.png";
8
+ import promoCodeArt from "../../assets/images/discount/promo.png";
9
+ import seatLevelArt from "../../assets/images/discount/seat.png";
10
+ import instantArt from "../../assets/images/discount/instant.png";
11
+
12
+ export type ImageRef =
13
+ | string
14
+ | { src?: string; default?: string | { src?: string } };
15
+
16
+ export function toImageUrl(art: ImageRef): string {
17
+ if (typeof art === "string") return art;
18
+ if (art && typeof art === "object") {
19
+ if (typeof art.default === "string") return art.default;
20
+ if (art.default && typeof art.default === "object" && art.default.src) {
21
+ return art.default.src;
22
+ }
23
+ if (art.src) return art.src;
24
+ }
25
+ return "";
26
+ }
27
+
28
+ export const DEFAULT_BACKGROUND_IMAGES: string[] = [
29
+ greenArt,
30
+ yellowArt,
31
+ blueArt,
32
+ ].map(toImageUrl);
33
+
34
+ export const CAMPAIGN_ART: Record<string, { image: string; accent: string }> = {
35
+ last_minute: { image: toImageUrl(lastMinuteArt), accent: "#4457C7" },
36
+ first_time: { image: toImageUrl(firstTimeArt), accent: "#D63384" },
37
+ cashback: { image: toImageUrl(cashbackArt), accent: "#0069D1" },
38
+ round_trip: { image: toImageUrl(roundTripArt), accent: "#5C8A00" },
39
+ promo_code: { image: toImageUrl(promoCodeArt), accent: "#E08A00" },
40
+ seat_level: { image: toImageUrl(seatLevelArt), accent: "#B87500" },
41
+ instant_discount: { image: toImageUrl(instantArt), accent: "#107E23" },
42
+ };
@@ -0,0 +1,166 @@
1
+ import CommonService from "../../utils/CommonService";
2
+ import {
3
+ DiscountBannerCard,
4
+ DiscountBannerServiceItem,
5
+ WowDealData,
6
+ } from "./types";
7
+
8
+ const DESCUENTOS_ESPECIALES_KEY = "descuentos_especiales";
9
+ const DESCUENTOS_ESPECIALES_LABEL = "Descuentos especiales";
10
+ const REMATE_KEY = "remate";
11
+
12
+ export const CAMPAIGN_TYPE_LABELS: Record<string, string> = {
13
+ last_minute: "Última hora",
14
+ first_time: "Primera compra",
15
+ cashback: "Cashback",
16
+ return_booking: "Pasaje de vuelta",
17
+ round_trip: "Ida y vuelta",
18
+ early_bird: "Compra anticipada",
19
+ promo_code: "Código promocional",
20
+ seat_level: "Descuento por asiento",
21
+ instant_discount: "Descuento inmediato",
22
+ };
23
+ const ACCENTED_CHARS: Record<string, string> = {
24
+ á: "a",
25
+ é: "e",
26
+ í: "i",
27
+ ó: "o",
28
+ ú: "u",
29
+ ü: "u",
30
+ ñ: "n",
31
+ };
32
+
33
+ function slugify(text: string): string {
34
+ return text
35
+ .trim()
36
+ .toLowerCase()
37
+ .replace(/[áéíóúüñ]/g, (char) => ACCENTED_CHARS[char] ?? char)
38
+ .replace(/[^a-z0-9]+/g, "_")
39
+ .replace(/^_+|_+$/g, "");
40
+ }
41
+
42
+ interface ServiceDiscount {
43
+ type: "percentage" | "fixed";
44
+ amount: number;
45
+ }
46
+
47
+ // Dynamic-pricing discounts store one percent per seat type; the best (lowest) one is what's advertised elsewhere in this library
48
+ function getDpPercent(service: DiscountBannerServiceItem): number {
49
+ if (!service?.is_dp_enabled || !service?.dp_discount_percents) return 0;
50
+ const values = Object.values(service.dp_discount_percents)
51
+ .map(Number)
52
+ .filter((v) => isFinite(v) && v > 0);
53
+ return values.length ? Math.min(...values) : 0;
54
+ }
55
+
56
+ function getServiceDiscount(
57
+ service: DiscountBannerServiceItem,
58
+ ): ServiceDiscount | null {
59
+ const dpPercent = getDpPercent(service);
60
+ if (dpPercent > 0) return { type: "percentage", amount: dpPercent };
61
+
62
+ if (
63
+ service?.discount_type === "percentage" &&
64
+ service?.discount_value != null &&
65
+ service.discount_value > 0
66
+ ) {
67
+ return { type: "percentage", amount: Number(service.discount_value) };
68
+ }
69
+
70
+ if (
71
+ service?.discount_type === "fixed" &&
72
+ service?.discount_value != null &&
73
+ service.discount_value > 0
74
+ ) {
75
+ const rawDiscount = Number(service.discount_value);
76
+ const { max_discount } = service;
77
+ const amount =
78
+ max_discount != null && max_discount > 0
79
+ ? Math.min(rawDiscount, max_discount)
80
+ : rawDiscount;
81
+ return amount > 0 ? { type: "fixed", amount } : null;
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ function toDiscountLabel(
88
+ discount: ServiceDiscount,
89
+ currencySign: string,
90
+ ): string {
91
+ if (discount.type === "fixed") {
92
+ return `${CommonService.currencyRounded(discount.amount, currencySign)} de dcto`;
93
+ }
94
+ return `${Math.round(discount.amount)}% de dcto`;
95
+ }
96
+
97
+ // Groups services into one card per discount category. Remate/wow-deal is always the first card.
98
+ export function deriveDiscountCards(
99
+ services: DiscountBannerServiceItem[] = [],
100
+ wowDealData?: WowDealData,
101
+ currencySign = "$",
102
+ ): DiscountBannerCard[] {
103
+ const cards = new Map<string, DiscountBannerCard>();
104
+
105
+ const wowServices = wowDealData?.services;
106
+ const hasWowDeal =
107
+ !!wowDealData &&
108
+ ((Array.isArray(wowServices) && wowServices.length > 0) ||
109
+ !!wowDealData.savings_percent);
110
+
111
+ if (hasWowDeal) {
112
+ const remateDiscount: ServiceDiscount = {
113
+ type: "percentage",
114
+ amount: Math.round(wowDealData!.savings_percent || 0),
115
+ };
116
+ cards.set(REMATE_KEY, {
117
+ key: REMATE_KEY,
118
+ subtitle: "Remate",
119
+ discountType: remateDiscount.type,
120
+ amount: remateDiscount.amount,
121
+ discountLabel: toDiscountLabel(remateDiscount, currencySign),
122
+ matchedServices: Array.isArray(wowServices) ? wowServices : [],
123
+ isWowDeal: true,
124
+ });
125
+ }
126
+
127
+ for (const service of services || []) {
128
+ const discount = getServiceDiscount(service);
129
+ if (!discount) continue;
130
+
131
+ const campaignType = service.discounts?.[0]?.campaign_type;
132
+ const campaignLabel = campaignType
133
+ ? CAMPAIGN_TYPE_LABELS[campaignType]
134
+ : undefined;
135
+
136
+ const category = campaignType
137
+ ? slugify(campaignType)
138
+ : service.offer_text
139
+ ? slugify(service.offer_text)
140
+ : DESCUENTOS_ESPECIALES_KEY;
141
+ const subtitle = campaignLabel || DESCUENTOS_ESPECIALES_LABEL;
142
+ const roundedAmount = Math.round(discount.amount);
143
+ const key = `${category}_${discount.type}_${roundedAmount}`;
144
+
145
+ const existing = cards.get(key);
146
+ if (existing) {
147
+ existing.matchedServices.push(service);
148
+ } else {
149
+ cards.set(key, {
150
+ key,
151
+ subtitle,
152
+ campaignType,
153
+ discountType: discount.type,
154
+ amount: roundedAmount,
155
+ discountLabel: toDiscountLabel(
156
+ { type: discount.type, amount: roundedAmount },
157
+ currencySign,
158
+ ),
159
+ matchedServices: [service],
160
+ isWowDeal: false,
161
+ });
162
+ }
163
+ }
164
+
165
+ return Array.from(cards.values());
166
+ }
@@ -0,0 +1,14 @@
1
+ export { default as DiscountBanners } from "./DiscountBanners";
2
+ export { deriveDiscountCards, CAMPAIGN_TYPE_LABELS } from "./discountUtils";
3
+ export {
4
+ CAMPAIGN_ART,
5
+ DEFAULT_BACKGROUND_IMAGES,
6
+ toImageUrl,
7
+ } from "./defaultBackgrounds";
8
+ export type { ImageRef } from "./defaultBackgrounds";
9
+ export type {
10
+ DiscountBannerCard,
11
+ DiscountBannerServiceItem,
12
+ DiscountBannersProps,
13
+ WowDealData,
14
+ } from "./types";
@@ -0,0 +1,53 @@
1
+ import type { CSSProperties } from "react";
2
+ import type { ImageRef } from "./defaultBackgrounds";
3
+
4
+ export interface DiscountBannerServiceItem {
5
+ id?: string | number;
6
+ offer_text?: string;
7
+ is_dp_enabled?: boolean;
8
+ dp_discount_percents?: Record<string, number>;
9
+ dp_discounted_seats?: string[];
10
+ discount_type?: string;
11
+ discount_value?: number;
12
+ max_discount?: number;
13
+ discounts?: Array<{ new_ui_enabled?: boolean; campaign_type?: string }>;
14
+ [key: string]: any;
15
+ }
16
+
17
+ export interface WowDealData {
18
+ savings_percent?: number;
19
+ services?: any[];
20
+ final_price?: number;
21
+ original_price?: number;
22
+ [key: string]: any;
23
+ }
24
+
25
+ export interface DiscountBannerCard {
26
+ key: string;
27
+ subtitle: string;
28
+ campaignType?: string;
29
+ discountType: "percentage" | "fixed";
30
+ amount: number;
31
+ discountLabel: string;
32
+ matchedServices: DiscountBannerServiceItem[];
33
+ isWowDeal: boolean;
34
+ }
35
+
36
+ export interface DiscountBannersProps {
37
+ services?: DiscountBannerServiceItem[];
38
+ wowDealData?: WowDealData;
39
+ backgroundImages?: ImageRef[];
40
+ artIncludesChipPill?: boolean;
41
+ accentColors?: string[];
42
+ label?: string;
43
+ currencySign?: string;
44
+ size?: "default" | "compact";
45
+ selectedKey?: string | null;
46
+ defaultSelectedKey?: string | null;
47
+ onDiscountSelect?: (
48
+ card: DiscountBannerCard | null,
49
+ filteredServices: DiscountBannerServiceItem[],
50
+ ) => void;
51
+ className?: string;
52
+ style?: CSSProperties;
53
+ }
@@ -97,7 +97,7 @@ const ServiceBadges: React.FC<ServiceBadgesProps> = ({
97
97
  <div>{"Tren Express"}</div>
98
98
  </div>
99
99
  )}
100
- {boardingName && isAllinBus && (
100
+ {boardingName && isAllinBus && !showTopLabel && (
101
101
  <div
102
102
  className={`flex items-center gap-[10px] py-[4px] px-[14px] rounded-[38px] text-[12.5px] z-20`}
103
103
  style={{
@@ -104,7 +104,7 @@ const ServiceBadgesMobile: React.FC<ServiceBadgesMobileProps> = ({
104
104
  <div>{"Tren Express"}</div>
105
105
  </div>
106
106
  )}
107
- {boardingName && isAllinBus && (
107
+ {boardingName && isAllinBus && !showTopLabel && (
108
108
  <div
109
109
  className={`flex items-center gap-[2p x] py-[4px] px-[10px] rounded-[38px] min-[420]:text-[12px] text-[10px] h-[24px] z-20`}
110
110
  style={{