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.
- package/dist/KuposUIComponent.d.ts +1 -1
- package/dist/KuposUIComponent.js +3 -0
- package/dist/assets/images/discount/blue.png +0 -0
- package/dist/assets/images/discount/cash.png +0 -0
- package/dist/assets/images/discount/first.png +0 -0
- package/dist/assets/images/discount/green.png +0 -0
- package/dist/assets/images/discount/instant.png +0 -0
- package/dist/assets/images/discount/last.png +0 -0
- package/dist/assets/images/discount/promo.png +0 -0
- package/dist/assets/images/discount/round.png +0 -0
- package/dist/assets/images/discount/seat.png +0 -0
- package/dist/assets/images/discount/yellow.png +0 -0
- package/dist/assets/images/index.d.ts +12 -0
- package/dist/assets/images/index.js +12 -0
- package/dist/assets/images/index.ts +14 -0
- package/dist/components/DiscountBanners/DiscountBannerDesktop.d.ts +11 -0
- package/dist/components/DiscountBanners/DiscountBannerDesktop.js +133 -0
- package/dist/components/DiscountBanners/DiscountBannerMobile.d.ts +11 -0
- package/dist/components/DiscountBanners/DiscountBannerMobile.js +132 -0
- package/dist/components/DiscountBanners/ResponsiveDiscountBanner.d.ts +3 -0
- package/dist/components/DiscountBanners/ResponsiveDiscountBanner.js +10 -0
- package/dist/components/DiscountBanners/index.d.ts +5 -0
- package/dist/components/DiscountBanners/index.js +4 -0
- package/dist/components/DiscountBanners/types.d.ts +51 -0
- package/dist/components/DiscountBanners/types.js +1 -0
- package/dist/components/ServiceItem/ServiceItemDesktop.js +1 -1
- package/dist/components/ServiceItem/ServiceItemMobile.js +0 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +6 -1
- package/dist/styles.css +89 -0
- package/dist/ui/DiscountBanners/DiscountBanners.d.ts +4 -0
- package/dist/ui/DiscountBanners/DiscountBanners.js +114 -0
- package/dist/ui/DiscountBanners/defaultBackgrounds.d.ts +12 -0
- package/dist/ui/DiscountBanners/defaultBackgrounds.js +38 -0
- package/dist/ui/DiscountBanners/discountUtils.d.ts +3 -0
- package/dist/ui/DiscountBanners/discountUtils.js +126 -0
- package/dist/ui/DiscountBanners/index.d.ts +5 -0
- package/dist/ui/DiscountBanners/index.js +3 -0
- package/dist/ui/DiscountBanners/types.d.ts +49 -0
- package/dist/ui/DiscountBanners/types.js +1 -0
- package/dist/ui/ServiceBadges/ServiceBadges.js +1 -1
- package/dist/ui/mobileweb/ServiceBadgesMobile.js +1 -1
- package/package.json +21 -6
- package/src/KuposUIComponent.tsx +13 -1
- package/src/assets/images/discount/blue.png +0 -0
- package/src/assets/images/discount/cash.png +0 -0
- package/src/assets/images/discount/first.png +0 -0
- package/src/assets/images/discount/green.png +0 -0
- package/src/assets/images/discount/instant.png +0 -0
- package/src/assets/images/discount/last.png +0 -0
- package/src/assets/images/discount/promo.png +0 -0
- package/src/assets/images/discount/round.png +0 -0
- package/src/assets/images/discount/seat.png +0 -0
- package/src/assets/images/discount/yellow.png +0 -0
- package/src/assets/images/index.ts +14 -0
- package/src/components/DiscountBanners/DiscountBannerDesktop.tsx +240 -0
- package/src/components/DiscountBanners/DiscountBannerMobile.tsx +234 -0
- package/src/components/DiscountBanners/ResponsiveDiscountBanner.tsx +16 -0
- package/src/components/DiscountBanners/index.ts +16 -0
- package/src/components/DiscountBanners/types.ts +55 -0
- package/src/components/ServiceItem/ServiceItemDesktop.tsx +1 -0
- package/src/components/ServiceItem/ServiceItemMobile.tsx +0 -1
- package/src/index.ts +34 -3
- package/src/types/images.d.ts +12 -0
- package/src/ui/DiscountBanners/DiscountBanners.tsx +215 -0
- package/src/ui/DiscountBanners/defaultBackgrounds.ts +42 -0
- package/src/ui/DiscountBanners/discountUtils.ts +166 -0
- package/src/ui/DiscountBanners/index.ts +14 -0
- package/src/ui/DiscountBanners/types.ts +53 -0
- package/src/ui/ServiceBadges/ServiceBadges.tsx +1 -1
- 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 {
|
|
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={{
|