kupos-ui-components-lib 9.3.1 → 9.3.3

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 (41) hide show
  1. package/dist/assets/images/anims/service_list/60_percent_anim.json +1 -0
  2. package/dist/assets/images/anims/service_list/dot_animation.json +1 -0
  3. package/dist/assets/images/anims/service_list/thunder_icon.json +1 -0
  4. package/dist/components/ServiceItem/ServiceItemDesktop.js +50 -121
  5. package/dist/components/ServiceItem/ServiceItemMobile.js +44 -105
  6. package/dist/components/ServiceItem/mobileTypes.d.ts +3 -0
  7. package/dist/components/ServiceItem/types.d.ts +2 -0
  8. package/dist/styles.css +125 -23
  9. package/dist/types.d.ts +2 -0
  10. package/dist/ui/ExpendedDropDown/ExpandedDropdown.js +16 -19
  11. package/dist/ui/FeatureServiceUI/FeatureServiceUi.d.ts +12 -0
  12. package/dist/ui/FeatureServiceUI/FeatureServiceUi.js +101 -0
  13. package/dist/ui/SeatSection/SeatSection.d.ts +2 -1
  14. package/dist/ui/SeatSection/SeatSection.js +41 -10
  15. package/dist/ui/ServiceBadges/ServiceBadges.d.ts +17 -0
  16. package/dist/ui/ServiceBadges/ServiceBadges.js +33 -0
  17. package/dist/ui/mobileweb/DateTimeSectionMobile.d.ts +2 -1
  18. package/dist/ui/mobileweb/DateTimeSectionMobile.js +2 -2
  19. package/dist/ui/mobileweb/ExpandedDropdownMobile.js +18 -18
  20. package/dist/ui/mobileweb/SeatSectionMobile.d.ts +2 -1
  21. package/dist/ui/mobileweb/SeatSectionMobile.js +28 -16
  22. package/dist/ui/mobileweb/ServiceBadgesMobile.d.ts +17 -0
  23. package/dist/ui/mobileweb/ServiceBadgesMobile.js +35 -0
  24. package/dist/utils/CommonService.d.ts +7 -0
  25. package/dist/utils/CommonService.js +61 -0
  26. package/package.json +1 -1
  27. package/src/assets/images/anims/service_list/dot_animation.json +1 -0
  28. package/src/components/ServiceItem/ServiceItemDesktop.tsx +93 -235
  29. package/src/components/ServiceItem/ServiceItemMobile.tsx +118 -166
  30. package/src/components/ServiceItem/mobileTypes.ts +3 -0
  31. package/src/components/ServiceItem/types.ts +2 -0
  32. package/src/styles.css +10 -0
  33. package/src/types.ts +2 -0
  34. package/src/ui/ExpendedDropDown/ExpandedDropdown.tsx +26 -67
  35. package/src/ui/SeatSection/SeatSection.tsx +87 -32
  36. package/src/ui/ServiceBadges/ServiceBadges.tsx +92 -0
  37. package/src/ui/mobileweb/DateTimeSectionMobile.tsx +3 -0
  38. package/src/ui/mobileweb/ExpandedDropdownMobile.tsx +24 -24
  39. package/src/ui/mobileweb/SeatSectionMobile.tsx +77 -32
  40. package/src/ui/mobileweb/ServiceBadgesMobile.tsx +92 -0
  41. package/src/utils/CommonService.ts +86 -0
@@ -18,6 +18,7 @@ interface SeatSectionProps {
18
18
  removeDuplicateSeats?: boolean;
19
19
  isPeru?: boolean;
20
20
  serviceItem?: any;
21
+ renderIcon?: (iconKey: string, size?: string) => React.ReactNode;
21
22
  }
22
23
 
23
24
  function getAllSeatTypes(seatTypes: SeatType[]) {
@@ -96,6 +97,7 @@ function SeatSection({
96
97
  removeDuplicateSeats,
97
98
  isPeru,
98
99
  serviceItem,
100
+ renderIcon,
99
101
  }: SeatSectionProps): React.ReactElement {
100
102
  const uniqueSeats = getUniqueSeats(seatTypes);
101
103
  const sortedSeatTypes = getSortedSeatTypes(seatTypes);
@@ -132,16 +134,13 @@ function SeatSection({
132
134
  if (isPeru) {
133
135
  const allSeats = getAllSeatTypes(seatTypes);
134
136
  const lowestFare = allSeats.length > 0 ? allSeats[0].price : 0;
135
- const { originalPrice, discountedPrice } =
136
- CommonService.calculateDiscountedPrice(lowestFare, serviceItem);
137
+ const { discountedPrice } = CommonService.calculateDiscountedPrice(
138
+ lowestFare,
139
+ serviceItem,
140
+ );
137
141
 
138
142
  return (
139
143
  <>
140
- {originalPrice !== discountedPrice && (
141
- <span className="text-[13.33px]" style={strikethroughStyle}>
142
- {formatPrice(originalPrice)}
143
- </span>
144
- )}
145
144
  <span className="flex items-center text-[13.33px] bold-text">
146
145
  {formatPrice(discountedPrice)}
147
146
  </span>
@@ -178,30 +177,26 @@ function SeatSection({
178
177
  });
179
178
  };
180
179
 
181
- const strikethroughStyle: React.CSSProperties = {
182
- color: "#ccc",
183
- display: "flex",
184
- textAlign: "end",
185
- textDecoration: "line-through",
186
- ...(isPeru
187
- ? { position: "relative", top: 0 }
188
- : { position: "absolute", top: isCentered ? "-10px" : "-18px" }),
189
- };
190
-
191
180
  const seats = removeDuplicateSeats ? uniqueSeats : sortedSeatTypes;
192
181
  const discountedSeats = seats.map((seat) => ({
193
182
  ...seat,
194
183
  ...CommonService.calculateDiscountedPrice(seat.price, serviceItem),
195
184
  }));
196
185
 
197
- const highestOriginalPrice = Math.max(
198
- ...discountedSeats.map((seat) => seat.originalPrice),
199
- );
200
-
201
186
  const hasDiscount = discountedSeats.some(
202
187
  (seat) => seat.originalPrice !== seat.discountedPrice,
203
188
  );
204
189
 
190
+ const discountSeat = discountedSeats
191
+ .filter((seat) => !SEAT_EXCEPTIONS.includes(seat.label))
192
+ .sort((a, b) => a.discountedPrice - b.discountedPrice)[0];
193
+
194
+ const discountValue =
195
+ serviceItem?.discount_type === "percentage" &&
196
+ typeof serviceItem?.discount_value === "number"
197
+ ? Math.round(serviceItem.discount_value)
198
+ : null;
199
+
205
200
  const renderLabels = () => {
206
201
  if (isPeru) {
207
202
  return (
@@ -225,6 +220,77 @@ function SeatSection({
225
220
  return renderSeatNames();
226
221
  };
227
222
 
223
+ if (hasDiscount && discountSeat) {
224
+ return (
225
+ <div className="grid items-center text-[13.33px] relative">
226
+ <div className="col-start-1 row-start-2 flex items-center">
227
+ <span className="text-[13.33px] font-normal leading-[22px] text-[#c2c2c2]">
228
+ Antes
229
+ </span>
230
+ </div>
231
+
232
+ <div className="col-start-1 row-start-3 flex h-[30px] items-end">
233
+ <span className="text-[13.33px] font-normal leading-[24px] text-[#464647]">
234
+ Desde
235
+ </span>
236
+ </div>
237
+
238
+ <div
239
+ className="col-start-2 row-start-1 flex items-center justify-center absolute"
240
+ style={{ top: "-22px", right: "30px" }}
241
+ >
242
+ {discountValue != null && (
243
+ <span
244
+ className="rounded-[100px] bg-[#ff5964] px-[6px] text-[12px] bold-text leading-[20px] text-white"
245
+ style={{
246
+ animation: "pulse-zoom 2s ease-in-out infinite",
247
+ }}
248
+ >
249
+ {discountValue}% OFF
250
+ </span>
251
+ )}
252
+ </div>
253
+
254
+ <div
255
+ className="col-start-2 row-start-2 flex items-center justify-center "
256
+ style={{ textAlign: "center" }}
257
+ >
258
+ <span
259
+ className="text-[13.33px] font-normal leading-[20px] text-[#9f9f9f] relative"
260
+ style={{
261
+ position: "relative",
262
+ }}
263
+ >
264
+ {formatPrice(discountSeat.originalPrice)}
265
+ <span
266
+ style={{
267
+ position: "absolute",
268
+ left: "-2px",
269
+ top: "50%",
270
+ width: "calc(100% + 4px)",
271
+ height: "1px",
272
+ backgroundColor: "#9f9f9f",
273
+ transform: "rotate(-10deg)",
274
+ transformOrigin: "center",
275
+ }}
276
+ />
277
+ </span>
278
+ </div>
279
+
280
+ <div className="col-start-2 row-start-3 flex h-[30px] items-end justify-start">
281
+ <span
282
+ className="flex items-center gap-[6px] text-[22px] bold-text leading-[30px]"
283
+ style={{ color: isSoldOut ? "#c0c0c0" : "#ff5964" }}
284
+ >
285
+ {/* <span className="text-[18px] leading-[24px]">🔥</span> */}
286
+ {renderIcon("fireIcon", "16px")}
287
+ {formatPrice(discountSeat.discountedPrice)}
288
+ </span>
289
+ </div>
290
+ </div>
291
+ );
292
+ }
293
+
228
294
  return (
229
295
  <div
230
296
  className="relative flex gap-[10px] text-[13.33px] justify-between min-h-[2.2rem]"
@@ -245,17 +311,6 @@ function SeatSection({
245
311
  gap: "10px",
246
312
  }}
247
313
  >
248
- {/* {isPeru && (
249
- <span className="text-[13.33px]" style={strikethroughStyle}>
250
- {formatPrice(1000)}
251
- </span>
252
- )} */}
253
-
254
- {hasDiscount && !isPeru && (
255
- <span className="text-[13.33px]" style={strikethroughStyle}>
256
- {formatPrice(highestOriginalPrice)}
257
- </span>
258
- )}
259
314
  {renderSeatPrices()}
260
315
  </div>
261
316
  </div>
@@ -0,0 +1,92 @@
1
+ import React from "react";
2
+
3
+ interface ServiceBadgesProps {
4
+ showTopLabel?: string;
5
+ isSoldOut: boolean;
6
+ colors: any;
7
+ renderIcon: (iconKey: string, size?: string) => React.ReactNode;
8
+ translation?: {
9
+ directService?: string;
10
+ };
11
+ serviceItem: {
12
+ is_transpordo?: boolean;
13
+ is_direct_trip?: boolean;
14
+ train_type_label?: string;
15
+ };
16
+ }
17
+
18
+ const ServiceBadges: React.FC<ServiceBadgesProps> = ({
19
+ showTopLabel,
20
+ isSoldOut,
21
+ colors,
22
+ renderIcon,
23
+ translation,
24
+ serviceItem,
25
+ }) => {
26
+ return (
27
+ <div className="absolute -top-[11px] left-0 w-full flex items-center justify-end gap-[12px] pr-[22px] z-10">
28
+ {showTopLabel && (
29
+ <div
30
+ className={`flex items-center gap-[10px] py-[4px] px-[14px] rounded-[38px] text-[12.5px] z-10`}
31
+ style={{
32
+ backgroundColor: "#fff",
33
+ border: `1px solid ${colors.topLabelColor}`,
34
+ color: colors.topLabelColor,
35
+ }}
36
+ >
37
+ <div className={isSoldOut ? "grayscale" : ""}>
38
+ {renderIcon("specialDeparture", "12px")}
39
+ </div>
40
+ <div
41
+ className={
42
+ isSoldOut ? "text-white" : `text-[${colors.topLabelColor}]`
43
+ }
44
+ >
45
+ {showTopLabel}
46
+ </div>
47
+ </div>
48
+ )}
49
+ {serviceItem?.is_transpordo && (
50
+ <div
51
+ className={`flex items-center gap-[10px] py-[4px] px-[14px] rounded-[38px] text-[12.5px] z-20`}
52
+ style={{
53
+ backgroundColor: "#fff",
54
+ border: `1px solid ${colors.topLabelColor}`,
55
+ color: colors.topLabelColor,
56
+ }}
57
+ >
58
+ {renderIcon("connectingServiceIcon", "12px")}
59
+ <div>{"Conexión"}</div>
60
+ </div>
61
+ )}
62
+ {serviceItem?.is_direct_trip && (
63
+ <div
64
+ className={`flex items-center gap-[10px] py-[4px] px-[14px] rounded-[38px] text-[12.5px] z-20`}
65
+ style={{
66
+ backgroundColor: "#fff",
67
+ border: `1px solid ${colors.topLabelColor}`,
68
+ color: colors.topLabelColor,
69
+ }}
70
+ >
71
+ {renderIcon("directo", "12px")}
72
+ <div>{translation?.directService}</div>
73
+ </div>
74
+ )}
75
+ {serviceItem?.train_type_label === "Tren Express (Nuevo)" && (
76
+ <div
77
+ className={`flex items-center gap-[10px] py-[4px] px-[14px] rounded-[38px] text-[12.5px] z-20`}
78
+ style={{
79
+ backgroundColor: "#fff",
80
+ border: `1px solid ${colors.topLabelColor}`,
81
+ color: colors.topLabelColor,
82
+ }}
83
+ >
84
+ {renderIcon("directo", "12px")}
85
+ <div>{"Tren Express"}</div>
86
+ </div>
87
+ )}
88
+ </div>
89
+ );
90
+ };
91
+
92
+ export default ServiceBadges;
@@ -22,6 +22,7 @@ interface DateTimeSectionMobileProps {
22
22
  availableSeats: number;
23
23
  removeDuplicateSeats?: boolean;
24
24
  serviceItem?: any;
25
+ tooltipBgColor?: string;
25
26
  }
26
27
 
27
28
  const pad = (n: number) => (n < 10 ? "0" + n : String(n));
@@ -119,6 +120,7 @@ function DateTimeSectionMobile({
119
120
  availableSeats,
120
121
  removeDuplicateSeats,
121
122
  serviceItem,
123
+ tooltipBgColor,
122
124
  }: DateTimeSectionMobileProps): React.ReactElement {
123
125
  const { cleaned: cleanedDepTime, hasAM, hasPM } = getCleanedDepTime(depTime);
124
126
 
@@ -187,6 +189,7 @@ function DateTimeSectionMobile({
187
189
  availableSeats={availableSeats}
188
190
  removeDuplicateSeats={removeDuplicateSeats}
189
191
  serviceItem={serviceItem}
192
+ tooltipBgColor={tooltipBgColor}
190
193
  />
191
194
  </div>
192
195
  );
@@ -36,6 +36,30 @@ function ExpandedDropdownMobile({
36
36
  className="flex flex-col gap-[8px] text-[11px] min-[420px]:text-[12px] text-[#464647]"
37
37
  style={{ lineHeight: 1.6 }}
38
38
  >
39
+ {isPeru ? null : isChangeTicket ? (
40
+ <div className="flex gap-[6px]">
41
+ <span style={{ marginTop: "2px" }}>•</span>
42
+ <span>
43
+ <span className="bold-text">Pasaje flexible:</span> Tu pasaje
44
+ puede ser cambiado de manera online{" "}
45
+ <span className="bold-text">
46
+ hasta {serviceItem?.change_ticket_hours || 6} horas antes
47
+ </span>{" "}
48
+ de la salida del bus. El monto será reembolsado a tu billetera
49
+ kupospay.
50
+ </span>
51
+ </div>
52
+ ) : (
53
+ <div className="flex gap-[8px] ">
54
+ <span style={{ marginTop: "2px" }}>•</span>
55
+ <span>
56
+ <span>
57
+ <span className="bold-text">Pasaje flexible:</span> Esta empresa
58
+ no permite cambios de pasajes
59
+ </span>
60
+ </span>
61
+ </div>
62
+ )}
39
63
  {petSeatInfo && Object.keys(petSeatInfo).length > 0 ? (
40
64
  <div className="flex items-center">
41
65
  <div className={`relative group cursor-default `}>
@@ -72,30 +96,6 @@ function ExpandedDropdownMobile({
72
96
  salida del bus.
73
97
  </span>
74
98
  </div>
75
- {isPeru ? null : isChangeTicket ? (
76
- <div className="flex gap-[6px]">
77
- <span style={{ marginTop: "2px" }}>•</span>
78
- <span>
79
- <span className="bold-text">Políticas de cambios:</span> Tu pasaje
80
- puede ser cambiado de manera online{" "}
81
- <span className="bold-text">
82
- hasta {serviceItem?.change_ticket_hours || 6} horas antes
83
- </span>{" "}
84
- de la salida del bus. El monto será reembolsado a tu billetera
85
- kupospay.
86
- </span>
87
- </div>
88
- ) : (
89
- <div className="flex gap-[8px] ">
90
- <span style={{ marginTop: "2px" }}>•</span>
91
- <span>
92
- <span>
93
- <span className="bold-text">Política de cambios:</span> Esta
94
- empresa no permite cambios de pasajes
95
- </span>
96
- </span>
97
- </div>
98
- )}
99
99
  </div>
100
100
  </div>
101
101
  );
@@ -28,6 +28,7 @@ interface SeatSectionMobileProps {
28
28
  availableSeats: number;
29
29
  removeDuplicateSeats?: boolean;
30
30
  serviceItem?: any;
31
+ tooltipBgColor?: string;
31
32
  }
32
33
 
33
34
  interface SeatRowProps {
@@ -112,6 +113,7 @@ function SeatSectionMobile({
112
113
  availableSeats,
113
114
  removeDuplicateSeats,
114
115
  serviceItem,
116
+ tooltipBgColor,
115
117
  }: SeatSectionMobileProps): React.ReactElement {
116
118
  const hasMultipleTypes = (seatTypesData?.length ?? 0) > 2;
117
119
 
@@ -137,14 +139,6 @@ function SeatSectionMobile({
137
139
  );
138
140
  };
139
141
 
140
- const getHighestFare = (): number => {
141
- return (
142
- seatTypesData
143
- ?.filter((item) => !EXCEPTIONS.includes(item.label))
144
- ?.reduce((max, item) => (item.fare > max ? item.fare : max), 0) || 0
145
- );
146
- };
147
-
148
142
  const renderPeruSeats = () => {
149
143
  const lowestFare = getLowestFare();
150
144
  if (lowestFare === null) return null;
@@ -242,42 +236,93 @@ function SeatSectionMobile({
242
236
  ...commonService.calculateDiscountedPrice(seat.fare, serviceItem),
243
237
  }));
244
238
 
245
- const highestOriginalPrice = Math.max(
246
- ...(discountedSeats?.map((s) => s.originalPrice) || [0]),
247
- );
248
239
  const hasDiscount = discountedSeats?.some(
249
240
  (s) => s.originalPrice !== s.discountedPrice,
250
241
  );
242
+ const discountSeat = discountedSeats
243
+ ?.filter((seat) => !EXCEPTIONS.includes(seat.label))
244
+ ?.sort((a, b) => a.discountedPrice - b.discountedPrice)[0];
245
+ const discountValue =
246
+ serviceItem?.discount_type === "percentage" &&
247
+ typeof serviceItem?.discount_value === "number"
248
+ ? Math.round(serviceItem.discount_value)
249
+ : null;
251
250
 
252
251
  return (
253
252
  <div className="content-center relative" style={{ width: "40%" }}>
254
- {!isPeru && hasDiscount && (
255
- <div className="absolute -top-[16px] right-[0] flex justify-end">
253
+ {hasDiscount && discountSeat ? (
254
+ <div className="relative grid grid-cols-[auto_auto] justify-between gap-x-[8px] ">
255
+ {discountValue != null && (
256
+ <div
257
+ className="absolute -top-[18px] right-[0px]"
258
+ style={{
259
+ animation: "pulse-zoom 2s ease-in-out infinite",
260
+ }}
261
+ >
262
+ <span
263
+ className="rounded-[100px] px-[8px] text-[12px] bold-text leading-[20px] text-[#fff]"
264
+ style={{
265
+ backgroundColor: tooltipBgColor,
266
+ }}
267
+ >
268
+ {discountValue}% OFF
269
+ </span>
270
+ </div>
271
+ )}
272
+
273
+ <span className="min-[420]:text-[13px] text-[12px] leading-[20px] text-[#c2c2c2]">
274
+ Antes
275
+ </span>
276
+ <span className="min-[420]:text-[13px] text-[12px] leading-[20px] text-[#9f9f9f] line-through text-right">
277
+ {commonService.currency(discountSeat.originalPrice, currencySign)}
278
+ </span>
279
+
256
280
  <span
257
- className="min-[420]:text-[13px] text-[12px] line-through"
258
- style={{ color: "#bbb" }}
281
+ className="min-[420]:text-[13px] text-[12px] leading-[24px]"
282
+ style={{ color: isSoldOut ? "#bbb" : "#464647" }}
259
283
  >
260
- {commonService.currency(highestOriginalPrice, currencySign)}
284
+ Desde
285
+ </span>
286
+ <span
287
+ className="flex items-center justify-end gap-[4px] text-[14px] bold-text leading-[24px]"
288
+ style={{ color: isSoldOut ? "#bbb" : "#ff5964" }}
289
+ >
290
+ {serviceItem?.icons?.fireIcon ? (
291
+ <img
292
+ src={serviceItem.icons.fireIcon}
293
+ alt="discount"
294
+ className="h-[16px] w-[16px] object-contain"
295
+ style={{ filter: isSoldOut ? "grayscale" : "" }}
296
+ />
297
+ ) : null}
298
+ {commonService.currency(discountSeat.discountedPrice, currencySign)}
261
299
  </span>
262
- </div>
263
- )}
264
- <div
265
- className="flex flex-col justify-between h-[2.5rem] "
266
- style={{
267
- gap: isSoldOut ? "0px" : "5px",
268
- justifyContent: hasMultipleTypes ? "space-between" : "center",
269
- }}
270
- >
271
- {renderSeats()}
272
300
 
273
- {isSoldOut ? (
274
- <div className="flex justify-end">
275
- <span className="min-[420]:text-[13px] text-[12px] text-[#ccc]">
301
+ {isSoldOut ? (
302
+ <span className="col-span-2 min-[420]:text-[13px] text-right text-[12px] text-[#ccc]">
276
303
  Agotado
277
304
  </span>
278
- </div>
279
- ) : null}
280
- </div>
305
+ ) : null}
306
+ </div>
307
+ ) : (
308
+ <div
309
+ className="flex flex-col justify-between h-[2.5rem] "
310
+ style={{
311
+ gap: isSoldOut ? "0px" : "5px",
312
+ justifyContent: hasMultipleTypes ? "space-between" : "center",
313
+ }}
314
+ >
315
+ {renderSeats()}
316
+
317
+ {isSoldOut ? (
318
+ <div className="flex justify-end">
319
+ <span className="min-[420]:text-[13px] text-[12px] text-[#ccc]">
320
+ Agotado
321
+ </span>
322
+ </div>
323
+ ) : null}
324
+ </div>
325
+ )}
281
326
  </div>
282
327
  );
283
328
  }
@@ -0,0 +1,92 @@
1
+ import React from "react";
2
+
3
+ interface ServiceBadgesMobileProps {
4
+ showTopLabel?: string;
5
+ isSoldOut: boolean;
6
+ colors: any;
7
+ renderIcon: (iconKey: string, size?: string) => React.ReactNode;
8
+ translation?: {
9
+ directService?: string;
10
+ };
11
+ serviceItem: {
12
+ is_direct_trip?: boolean;
13
+ train_type_label?: string;
14
+ };
15
+ isConexion?: boolean;
16
+ }
17
+
18
+ const ServiceBadgesMobile: React.FC<ServiceBadgesMobileProps> = ({
19
+ showTopLabel,
20
+ isSoldOut,
21
+ colors,
22
+ renderIcon,
23
+ serviceItem,
24
+ isConexion,
25
+ }) => {
26
+ return (
27
+ <div className="absolute -top-[9px] left-0 w-full flex items-center justify-end gap-[12px] pr-[20px] z-10">
28
+ {showTopLabel && (
29
+ <div
30
+ className={`flex items-center gap-[2px] py-[4px] px-[10px] rounded-[38px] min-[420]:text-[12px] text-[10px] z-20`}
31
+ style={{
32
+ backgroundColor: "#fff",
33
+ border: `1px solid ${colors.topLabelColor}`,
34
+ color: colors.topLabelColor,
35
+ }}
36
+ >
37
+ <div className={isSoldOut ? "grayscale" : ""}>
38
+ {renderIcon("specialDeparture", "12px")}
39
+ </div>
40
+ <div
41
+ style={{
42
+ color: colors.topLabelColor,
43
+ }}
44
+ >
45
+ {showTopLabel}
46
+ </div>
47
+ </div>
48
+ )}
49
+ {serviceItem?.is_direct_trip && (
50
+ <div
51
+ className={`flex items-center gap-[2px] py-[5px] px-[10px] rounded-[38px] min-[420]:text-[12px] text-[10px] z-20`}
52
+ style={{
53
+ backgroundColor: "#fff",
54
+ border: `1px solid ${colors.topLabelColor}`,
55
+ color: colors.topLabelColor,
56
+ }}
57
+ >
58
+ {renderIcon("directo", "12px")}
59
+ <div className="ml-[5px]">Directo</div>
60
+ </div>
61
+ )}
62
+ {isConexion && (
63
+ <div
64
+ className={`flex items-center gap-[2px] py-[5px] text-white px-[10px] rounded-[38px] min-[420]:text-[12px] text-[11px] z-20`}
65
+ style={{
66
+ backgroundColor: "#fff",
67
+ border: `1px solid ${colors.topLabelColor}`,
68
+ color: colors.topLabelColor,
69
+ }}
70
+ >
71
+ {renderIcon("airportIcon", "14px")}
72
+ <div>Conexión</div>
73
+ </div>
74
+ )}
75
+ {serviceItem?.train_type_label === "Tren Express (Nuevo)" && (
76
+ <div
77
+ className={`flex items-center gap-[2px] py-[5px] text-white px-[10px] rounded-[38px] min-[420]:text-[12px] text-[10px] z-20`}
78
+ style={{
79
+ backgroundColor: "#fff",
80
+ border: `1px solid ${colors.topLabelColor}`,
81
+ color: colors.topLabelColor,
82
+ }}
83
+ >
84
+ {renderIcon("directo", "12px")}
85
+ <div>{"Tren Express"}</div>
86
+ </div>
87
+ )}
88
+ </div>
89
+ );
90
+ };
91
+
92
+ export default ServiceBadgesMobile;
@@ -308,6 +308,92 @@ const commonService = {
308
308
 
309
309
  return { originalPrice: price, discountedPrice };
310
310
  },
311
+
312
+ startViewerCount: (
313
+ node: HTMLSpanElement | null,
314
+ viewersConfig?: { min: number; max: number; interval?: number },
315
+ ) => {
316
+ if (!node || !viewersConfig) return;
317
+
318
+ const prevId = node.dataset.viewerId;
319
+ if (prevId) clearInterval(Number(prevId));
320
+
321
+ const { min, max, interval = 5000 } = viewersConfig;
322
+ const clamp = (v: number) => Math.min(max, Math.max(min, v));
323
+ const initialValue = Math.floor(Math.random() * (max - min + 1)) + min;
324
+
325
+ node.textContent = String(initialValue);
326
+
327
+ const id = setInterval(() => {
328
+ const current = Number(node.textContent) || initialValue;
329
+ const delta = Math.ceil(current * 0.2);
330
+ const next =
331
+ current + Math.floor(Math.random() * (2 * delta + 1)) - delta;
332
+ node.textContent = String(clamp(Math.round(next)));
333
+ }, interval);
334
+
335
+ node.dataset.viewerId = String(id);
336
+ },
337
+
338
+ startCountdown: (
339
+ node: HTMLSpanElement | null,
340
+ countdownSeconds: number = 599,
341
+ ) => {
342
+ if (!node) return;
343
+
344
+ const prevId = node.dataset.countdownId;
345
+ if (prevId) clearInterval(Number(prevId));
346
+
347
+ let remaining = countdownSeconds * 1000; // Convert to milliseconds
348
+
349
+ const formatTime = (totalMs: number) => {
350
+ const m = Math.floor(totalMs / 60000);
351
+ const s = Math.floor((totalMs % 60000) / 1000);
352
+ const ms = Math.floor((totalMs % 1000) / 10); // Show 2 digits for milliseconds
353
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}:${String(ms).padStart(2, "0")}`;
354
+ };
355
+
356
+ node.textContent = formatTime(remaining);
357
+
358
+ const id = setInterval(() => {
359
+ remaining -= 100; // Decrease by 100ms
360
+ if (remaining <= 0) {
361
+ remaining = countdownSeconds * 1000;
362
+ }
363
+ node.textContent = formatTime(remaining);
364
+ }, 100); // Update every 100ms
365
+
366
+ node.dataset.countdownId = String(id);
367
+ },
368
+
369
+ startComprandoCount: (
370
+ node: HTMLSpanElement | null,
371
+ min: number = 4,
372
+ max: number = 16,
373
+ ) => {
374
+ if (!node) return;
375
+
376
+ const prevId = node.dataset.comprandoId;
377
+ if (prevId) clearInterval(Number(prevId));
378
+
379
+ const initialValue = Math.floor(Math.random() * (max - min + 1)) + min;
380
+ node.textContent = String(initialValue);
381
+
382
+ const id = setInterval(() => {
383
+ const current = Number(node.textContent) || initialValue;
384
+ const changePercent = 0.05; // 5% change
385
+ const change = Math.ceil(current * changePercent);
386
+ const direction = Math.random() > 0.5 ? 1 : -1;
387
+ let next = current + (change * direction);
388
+
389
+ // Clamp within min and max
390
+ next = Math.min(max, Math.max(min, next));
391
+
392
+ node.textContent = String(next);
393
+ }, 5000); // Update every 5 seconds
394
+
395
+ node.dataset.comprandoId = String(id);
396
+ },
311
397
  };
312
398
 
313
399
  export default commonService;