kupos-ui-components-lib 10.4.74 → 10.4.76

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.
@@ -1,16 +1,7 @@
1
1
  import React from "react";
2
2
  import LottiePlayer from "../../assets/LottiePlayer";
3
3
  import commonService from "../../utils/CommonService";
4
- import DateService from "../../utils/DateService";
5
- if (typeof document !== "undefined") {
6
- if (!document.getElementById("feature-spin-style")) {
7
- const style = document.createElement("style");
8
- style.id = "feature-spin-style";
9
- style.textContent =
10
- "@keyframes feature-spin { to { transform: rotate(360deg); } }";
11
- document.head.appendChild(style);
12
- }
13
- }
4
+ import { CARD_BORDER, CARD_SELECTED_BORDER, CARD_SELECTED_SHADOW, CalendarIcon, CrownIcon, DestinationIcon, OriginIcon, formatDepartureTime, formatTravelDate, setNodeText, } from "./featureServiceShared";
14
5
  const HARDCODED_OPERATORS = [
15
6
  {
16
7
  logo: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4e/Turbus_logo.svg/320px-Turbus_logo.svg.png",
@@ -32,8 +23,13 @@ const HARDCODED_OPERATORS = [
32
23
  },
33
24
  ];
34
25
  const expandedStateMap = {};
26
+ // Selected operator card per serviceItem.id (no hooks — same pattern as expandedStateMap)
27
+ const selectedCardMap = {};
28
+ // Whether the "select a card" error is visible per serviceItem.id
29
+ const selectErrorMap = {};
35
30
  const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOrigin, cityDestination, renderIcon, viewersConfig, isFeatureDropDownExpand, onToggleExpand, ticketQuantity = 1, onIncreaseTicketQuantity, onDecreaseTicketQuantity, onBookButtonPress, selectedTimeSlot, onTimeSlotChange, isTimeDropdownOpen, onTimeDropdownToggle, wowDealData = undefined, }) => {
36
- var _a, _b, _c, _d, _e;
31
+ var _a, _b, _c, _d;
32
+ console.log("🚀 ~ FeatureServiceUi ~ wowDealData:", wowDealData);
37
33
  // Use wow_deal services if available, otherwise fall back to serviceItem operators or hardcoded
38
34
  const operators = ((_a = wowDealData === null || wowDealData === void 0 ? void 0 : wowDealData.services) === null || _a === void 0 ? void 0 : _a.length) > 0
39
35
  ? wowDealData.services.slice(0, 3).map((service) => ({
@@ -88,29 +84,93 @@ const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOri
88
84
  })
89
85
  .sort((a, b) => a.final_price - b.final_price)
90
86
  : services;
91
- // The selected price (final and original) from the cheapest service in the active slot
92
- const selectedSlotService = servicesInActiveSlot === null || servicesInActiveSlot === void 0 ? void 0 : servicesInActiveSlot[0];
93
- const displayFinalPrice = selectedSlotService
94
- ? selectedSlotService.final_price
95
- : finalPrice;
96
- const displayOriginalPrice = selectedSlotService
97
- ? selectedSlotService.original_price
98
- : originalPrice;
99
- const displaySavingsPercent = selectedSlotService && selectedSlotService.original_price
100
- ? Math.round(((selectedSlotService.original_price -
101
- selectedSlotService.final_price) /
102
- selectedSlotService.original_price) *
103
- 100)
104
- : savingsPercent;
87
+ // All services in the deal window (time slot dropdown is disabled),
88
+ // cheapest first, then by departure time.
89
+ const sortedServices = [...services].sort((a, b) => {
90
+ var _a, _b;
91
+ return ((_a = a.final_price) !== null && _a !== void 0 ? _a : 0) - ((_b = b.final_price) !== null && _b !== void 0 ? _b : 0) ||
92
+ commonService.timeToMinutes(a.departure_time) -
93
+ commonService.timeToMinutes(b.departure_time);
94
+ });
95
+ // Cards shown in the middle column — scrollable when there are more than 3
96
+ const serviceCards = sortedServices.length > 0
97
+ ? sortedServices.map((s, idx) => {
98
+ var _a, _b;
99
+ return ({
100
+ key: String((_b = (_a = s === null || s === void 0 ? void 0 : s.id) !== null && _a !== void 0 ? _a : s === null || s === void 0 ? void 0 : s.service_id) !== null && _b !== void 0 ? _b : `${s === null || s === void 0 ? void 0 : s.operator_name}-${s === null || s === void 0 ? void 0 : s.departure_time}-${idx}`),
101
+ logo: s.operator_logo_url,
102
+ name: s.operator_name,
103
+ time: s.departure_time,
104
+ price: s.final_price,
105
+ seatsAvailable: `¡Solo quedan ${s.available_seats}!`,
106
+ service: s,
107
+ });
108
+ })
109
+ : operators.map((op, idx) => {
110
+ var _a;
111
+ return (Object.assign(Object.assign({}, op), { key: `fallback-${idx}`, price: (_a = op.price) !== null && _a !== void 0 ? _a : finalPrice, service: null }));
112
+ });
113
+ const hasCardScroll = serviceCards.length > 3;
114
+ // Default to the first (cheapest) card when nothing is selected yet,
115
+ // or when the previous selection is no longer in the list (data refresh)
116
+ if (serviceCards.length > 0 &&
117
+ !serviceCards.some((c) => c.key === selectedCardMap[serviceItem.id])) {
118
+ selectedCardMap[serviceItem.id] = serviceCards[0].key;
119
+ }
120
+ const selectedCard = serviceCards.find((c) => c.key === selectedCardMap[serviceItem.id]);
121
+ const isSelectErrorVisible = !selectedCard && !!selectErrorMap[serviceItem.id];
122
+ const getPriceInfo = (svc) => svc
123
+ ? {
124
+ final: svc.final_price,
125
+ original: svc.original_price,
126
+ savings: svc.original_price
127
+ ? Math.round(((svc.original_price - svc.final_price) / svc.original_price) *
128
+ 100)
129
+ : savingsPercent,
130
+ }
131
+ : { final: finalPrice, original: originalPrice, savings: savingsPercent };
132
+ // Price shown: the selected card's service, otherwise the cheapest service
133
+ const selectedSlotService = (selectedCard === null || selectedCard === void 0 ? void 0 : selectedCard.service) || sortedServices[0];
134
+ const { final: displayFinalPrice, original: displayOriginalPrice, savings: displaySavingsPercent, } = getPriceInfo(selectedSlotService);
135
+ const handleCardSelect = (key) => {
136
+ selectedCardMap[serviceItem.id] = key;
137
+ selectErrorMap[serviceItem.id] = false;
138
+ if (typeof document === "undefined")
139
+ return;
140
+ const container = document.getElementById(`feature-cards-${serviceItem.id}`);
141
+ container === null || container === void 0 ? void 0 : container.querySelectorAll("[data-card-key]").forEach((node) => {
142
+ const isSelected = node.dataset.cardKey === key;
143
+ node.style.border = isSelected ? CARD_SELECTED_BORDER : CARD_BORDER;
144
+ node.style.boxShadow = isSelected ? CARD_SELECTED_SHADOW : "none";
145
+ const check = node.querySelector("[data-card-check]");
146
+ if (check)
147
+ check.style.display = isSelected ? "flex" : "none";
148
+ });
149
+ const card = serviceCards.find((c) => c.key === key);
150
+ const price = getPriceInfo((card === null || card === void 0 ? void 0 : card.service) || sortedServices[0]);
151
+ setNodeText(document.getElementById(`feature-final-price-${serviceItem.id}`), commonService.currencyRounded(price.final * ticketQuantity));
152
+ setNodeText(document.getElementById(`feature-original-price-${serviceItem.id}`), commonService.currencyRounded(price.original * ticketQuantity));
153
+ document
154
+ .querySelectorAll(`[data-feature-savings="${serviceItem.id}"]`)
155
+ .forEach((node) => setNodeText(node, String(price.savings)));
156
+ const errorNode = document.getElementById(`feature-select-error-${serviceItem.id}`);
157
+ if (errorNode)
158
+ errorNode.style.display = "none";
159
+ };
105
160
  // The label shown on the dropdown button
106
161
  const departureRange = (activeSlot === null || activeSlot === void 0 ? void 0 : activeSlot.label) || `${dealWindowFrom} y ${dealWindowTo}`;
107
162
  // activeSlot?.label || `Entre ${dealWindowFrom} y ${dealWindowTo}`;
108
- const currentWindowFrom = activeSlot
109
- ? commonService.minutesToTime(activeSlot.start)
110
- : dealWindowFrom;
111
- const currentWindowTo = activeSlot
112
- ? commonService.minutesToTime(activeSlot.end === 1440 ? 1439 : activeSlot.end)
113
- : dealWindowTo;
163
+ // Time slot dropdown is disabled, so use the full deal window
164
+ const currentWindowFrom = dealWindowFrom;
165
+ const currentWindowTo = dealWindowTo;
166
+ // const currentWindowFrom = activeSlot
167
+ // ? commonService.minutesToTime(activeSlot.start)
168
+ // : dealWindowFrom;
169
+ // const currentWindowTo = activeSlot
170
+ // ? commonService.minutesToTime(
171
+ // activeSlot.end === 1440 ? 1439 : activeSlot.end,
172
+ // )
173
+ // : dealWindowTo;
114
174
  if (expandedStateMap[serviceItem.id] === undefined) {
115
175
  expandedStateMap[serviceItem.id] = true;
116
176
  }
@@ -183,8 +243,9 @@ const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOri
183
243
  fontSize: "12px",
184
244
  } },
185
245
  React.createElement("span", null,
186
- "AHORRAS ",
187
- displaySavingsPercent,
246
+ "AHORRAS",
247
+ " ",
248
+ React.createElement("span", { "data-feature-savings": serviceItem.id }, displaySavingsPercent),
188
249
  "%"))),
189
250
  React.createElement("div", { className: "flex items-center px-[12px] py-[6px] rounded-full", style: { backgroundColor: "rgba(255, 222, 223, 0.93)" } },
190
251
  React.createElement("div", { className: "mb-[2px] mr-[6px]" }, renderIcon("fireIcon", "14px")),
@@ -198,110 +259,77 @@ const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOri
198
259
  } }))),
199
260
  React.createElement("div", { id: `service-card-${serviceItem.id}`, className: "bg-[#0C1421] text-white mx-auto relative rounded-[14px] p-[14px] text-[13.33px]" },
200
261
  React.createElement("div", { className: "grid grid-cols-[27%_46%_27%] items-stretch" },
201
- React.createElement("div", { className: "flex flex-col justify-between gap-[20px] mb-[16px] pr-[22px]" },
202
- React.createElement("div", { className: "flex items-center gap-[6px]" },
203
- React.createElement("span", { className: "text-[16px] bold-text text-white tracking-wide" }, cityOrigin === null || cityOrigin === void 0 ? void 0 : cityOrigin.label.split(",")[0]),
204
- React.createElement("span", { className: "text-[15px] text-[#9b9b9b]" }, "\u2794"),
205
- React.createElement("span", { className: "text-[16px] bold-text text-white tracking-wide" }, cityDestination === null || cityDestination === void 0 ? void 0 : cityDestination.label.split(",")[0])),
206
- React.createElement("div", { className: "flex items-center w-fit rounded-[6px] px-[8px] py-[4px] gap-[6px] ", style: {
207
- border: "1px solid rgb(54, 60, 72)",
208
- backgroundColor: "rgb(26, 32, 46)",
209
- } },
210
- React.createElement("div", { className: "opacity-80" }, renderIcon("flexibleIcon", "14px")),
211
- React.createElement("span", { className: "text-[12px] text-white" }, "Salida flexible")),
212
- React.createElement("div", { className: "flex flex-col gap-[4px] mt-[4px]" },
213
- React.createElement("div", { className: "kupos-time-dd relative", tabIndex: 0, onBlur: (e) => {
214
- if (!e.currentTarget.contains(e.relatedTarget)) {
215
- onTimeDropdownToggle === null || onTimeDropdownToggle === void 0 ? void 0 : onTimeDropdownToggle(null);
216
- }
217
- }, style: { outline: "none" } },
218
- React.createElement("button", { type: "button", onClick: () => onTimeDropdownToggle === null || onTimeDropdownToggle === void 0 ? void 0 : onTimeDropdownToggle(isThisTimeDropdownOpen ? null : serviceItem.id), className: "flex whitespace-nowrap cursor-pointer select-none items-center gap-[6px] border-none bg-transparent p-0" },
219
- React.createElement("span", { className: "text-[14px] font-bold leading-none text-white tracking-wide" }, departureRange),
220
- React.createElement("img", { src: (_d = serviceItem === null || serviceItem === void 0 ? void 0 : serviceItem.icons) === null || _d === void 0 ? void 0 : _d.downArrow, alt: "down arrow", className: `kupos-time-chevron transition-transform duration-200 ${isThisTimeDropdownOpen ? "rotate-180" : "rotate-0"}`, style: {
221
- width: "12px",
222
- height: "8px",
223
- filter: "brightness(0) invert(1)",
224
- } })),
225
- isThisTimeDropdownOpen && (React.createElement(React.Fragment, null,
226
- React.createElement("div", { className: "absolute left-0 top-[calc(100%+10px)] hover:z-[200]", style: {
227
- zIndex: 20,
228
- backgroundColor: "#fff",
229
- borderRadius: "14px",
230
- minWidth: "190px",
231
- boxShadow: "0 8px 32px rgba(0,0,0,0.28)",
232
- overflow: "hidden",
233
- padding: "6px 0",
234
- } }, availableTimeSlots.map((slot) => {
235
- const isActive = slot.label === selectedTimeSlot ||
236
- slot === activeSlot;
237
- const count = services.filter((s) => {
238
- const depMin = commonService.timeToMinutes(s.departure_time);
239
- return depMin >= slot.start && depMin < slot.end;
240
- }).length;
241
- return (React.createElement("button", { key: slot.label, type: "button", onClick: () => {
242
- onTimeSlotChange === null || onTimeSlotChange === void 0 ? void 0 : onTimeSlotChange(slot.label);
243
- onTimeDropdownToggle === null || onTimeDropdownToggle === void 0 ? void 0 : onTimeDropdownToggle(null);
244
- }, className: `flex w-full cursor-pointer items-center justify-between gap-[10px] border-none px-[12px] py-[9px] text-left text-[13px] ${isActive ? "bg-[#FF5C60] font-bold text-[white]" : "bg-transparent font-normal text-[#1a1a1a]"}` },
245
- React.createElement("span", null, slot.label),
246
- count > 0 && (React.createElement("span", { className: `text-[11px] rounded-full px-[6px] py-[2px] font-bold ${isActive
247
- ? "bg-white text-[#FF5C60]"
248
- : "bg-[#FF5C60] text-white"}` }, count))));
249
- }))))),
250
- React.createElement("div", { className: "text-[13px] text-[#9b9b9b]" }, travelDate
251
- ? (() => {
252
- const dayName = DateService.getDayNameFromDate(travelDate, "yyyy-mm-dd", "full");
253
- const day = parseInt(DateService.getDateFromDate(travelDate, "yyyy-mm-dd"), 10);
254
- const monthName = DateService.getMonthNameFromDate(travelDate, "yyyy-mm-dd", "full");
255
- return `${dayName.charAt(0).toUpperCase() + dayName.slice(1)} ${day} de ${monthName}`;
256
- })()
257
- : "Viernes 23 de mayo")),
258
- React.createElement("div", { className: "flex flex-col items-start gap-[12px] text-[13px] mt-[6px]" },
259
- React.createElement("div", { className: "flex items-start gap-[10px]" },
260
- React.createElement("div", { className: "mt-[2px] opacity-80" }, renderIcon("confirmarIcon", "20px")),
261
- React.createElement("span", { className: "text-[#dcdcdc] leading-[1.3]" },
262
- "Operador confirmado ",
263
- React.createElement("br", null),
264
- " despu\u00E9s de tu compra")),
265
- React.createElement("div", { className: "flex items-start gap-[10px]" },
266
- React.createElement("div", { className: "mt-[2px] opacity-80" }, renderIcon("sheildIcon", "20px")),
267
- React.createElement("span", { className: "text-[#dcdcdc] leading-[1.3]" },
268
- "Si nadie confirma, ",
269
- React.createElement("br", null),
270
- " te devolvemos tu dinero")))),
271
- React.createElement("div", { className: "min-w-0 overflow-x-auto px-[22px] flex flex-col items-center justify-between gap-[16px] py-[2px] border-r border-[#363c48] border-l border-[#363c48]" },
262
+ React.createElement("div", { className: "flex flex-col gap-[14px] mb-[16px] pr-[22px]" },
263
+ React.createElement("div", { className: "mt-auto flex flex-col gap-[12px]" },
264
+ React.createElement("div", { className: "flex items-center gap-[12px]" },
265
+ React.createElement(OriginIcon, null),
266
+ React.createElement("span", { className: "text-[16px] bold-text text-white tracking-wide" }, cityOrigin === null || cityOrigin === void 0 ? void 0 : cityOrigin.label.split(",")[0])),
267
+ React.createElement("div", { className: "flex items-center gap-[12px]" },
268
+ React.createElement(DestinationIcon, null),
269
+ React.createElement("span", { className: "text-[16px] bold-text text-white tracking-wide" }, cityDestination === null || cityDestination === void 0 ? void 0 : cityDestination.label.split(",")[0]))),
270
+ React.createElement("div", { className: "flex flex-col gap-[4px]" },
271
+ React.createElement("div", { className: "flex items-center gap-[12px] text-[13px] text-white" },
272
+ React.createElement(CalendarIcon, null),
273
+ React.createElement("span", null, formatTravelDate(travelDate)))),
274
+ React.createElement("div", { className: "mt-auto flex items-center gap-[12px]" },
275
+ React.createElement("div", { className: "shrink-0" }, renderIcon("sheildIcon", "24px")),
276
+ React.createElement("div", { className: "flex flex-col gap-[2px]" },
277
+ React.createElement("span", { className: "bold-text text-[13px] text-white" }, "Precio Remate"),
278
+ React.createElement("span", { className: "text-[11px] text-[#dcdcdc]" }, "Sin cambios, sin cancelaci\u00F3n")))),
279
+ React.createElement("div", { className: "feature-cards-scroll min-w-0 overflow-x-auto px-[22px] flex flex-col items-center justify-between gap-[16px] py-[2px] border-r border-[#363c48] border-l border-[#363c48]" },
272
280
  React.createElement("div", { className: "text-center" },
273
281
  React.createElement("div", { className: "bold-text text-[13px]" },
274
- servicesInActiveSlot.length > 0
275
- ? servicesInActiveSlot.length
282
+ sortedServices.length > 0
283
+ ? sortedServices.length
276
284
  : operatorsCompetingCount,
277
285
  " ",
278
- "operadores compitiendo por tu compra")),
279
- React.createElement("div", { className: "grid items-stretch gap-[14px]", style: {
280
- gridTemplateColumns: `repeat(${Math.min(servicesInActiveSlot.length || operators.length, 3)}, minmax(80px, 1fr))`,
281
- } }, (servicesInActiveSlot.length > 0
282
- ? servicesInActiveSlot.slice(0, 3).map((s) => ({
283
- logo: s.operator_logo_url,
284
- name: s.operator_name,
285
- time: s.departure_time,
286
- seatsAvailable: `${s.available_seats} disponibles`,
287
- }))
288
- : operators).map((op, idx) => (React.createElement("div", { key: idx, className: "flex min-w-0 flex-col items-center justify-center gap-[8px] rounded-[8px]", style: {
289
- // border: "1px solid #363c48",
290
- // backgroundColor: "#fff",
291
- border: "1px solid #363c48",
292
- backgroundColor: "#1a202e",
293
- padding: "25px 10px",
294
- } },
295
- React.createElement("img", { src: op.logo, alt: op.name, onError: (e) => {
296
- var _a;
297
- e.currentTarget.src =
298
- ((_a = serviceItem === null || serviceItem === void 0 ? void 0 : serviceItem.operator_details) === null || _a === void 0 ? void 0 : _a[0]) ||
299
- "/images/service-list/bus-icon.svg";
300
- }, className: `h-[24px] max-w-full object-contain` }),
301
- React.createElement("span", { className: "text-[11px] truncate w-full text-center text-[white] overflow-hidden" }, op.name),
302
- React.createElement("div", { className: "bg-[#FF8F45] text-white text-[12px] font-bold px-[10px] py-[4px] rounded-[4px] bold-text whitespace-nowrap" },
303
- React.createElement("span", null, op === null || op === void 0 ? void 0 : op.time)),
304
- React.createElement("span", { className: "text-[10px] mt-[6px] text-[white]" }, op === null || op === void 0 ? void 0 : op.seatsAvailable))))),
286
+ "ofertas compitiendo por tu compra")),
287
+ React.createElement("div", { id: `feature-cards-${serviceItem.id}`, className: "feature-cards-scroll flex w-full items-stretch gap-[14px] overflow-x-auto", style: {
288
+ justifyContent: hasCardScroll ? "flex-start" : "center",
289
+ // room for the crown badge that sits above the selected card
290
+ paddingTop: "12px",
291
+ paddingBottom: 0,
292
+ scrollbarWidth: "none",
293
+ } }, serviceCards.map((op) => {
294
+ const isSelected = op.key === (selectedCard === null || selectedCard === void 0 ? void 0 : selectedCard.key);
295
+ return (React.createElement("div", { key: op.key, "data-card-key": op.key, role: "button", tabIndex: 0, "aria-pressed": isSelected, onClick: () => handleCardSelect(op.key), onKeyDown: (e) => {
296
+ if (e.key === "Enter" || e.key === " ") {
297
+ e.preventDefault();
298
+ handleCardSelect(op.key);
299
+ }
300
+ }, className: "relative flex min-w-0 cursor-pointer flex-col items-center justify-center gap-[8px] rounded-[8px] outline-none", style: {
301
+ // 3 cards visible; the rest scroll horizontally
302
+ flex: "0 0 calc((100% - 28px) / 3)",
303
+ minWidth: "80px",
304
+ border: isSelected ? CARD_SELECTED_BORDER : CARD_BORDER,
305
+ boxShadow: isSelected ? CARD_SELECTED_SHADOW : "none",
306
+ backgroundColor: "#1a202e",
307
+ padding: "20px 10px 16px",
308
+ borderRadius: "12px",
309
+ transition: "border-color 150ms ease, box-shadow 150ms ease",
310
+ } },
311
+ React.createElement("span", { "data-card-check": "true", className: "absolute items-center justify-center", style: {
312
+ display: isSelected ? "flex" : "none",
313
+ top: "-9px",
314
+ right: "7px",
315
+ width: "32px",
316
+ height: "20px",
317
+ borderRadius: "6px",
318
+ backgroundColor: "#FF5C60",
319
+ } },
320
+ React.createElement(CrownIcon, null)),
321
+ React.createElement("img", { src: op.logo, alt: op.name, onError: (e) => {
322
+ var _a;
323
+ e.currentTarget.src =
324
+ ((_a = serviceItem === null || serviceItem === void 0 ? void 0 : serviceItem.operator_details) === null || _a === void 0 ? void 0 : _a[0]) ||
325
+ "/images/service-list/bus-icon.svg";
326
+ }, className: `h-[24px] max-w-full object-contain` }),
327
+ React.createElement("span", { className: "text-[11px] truncate w-full text-center text-[white] overflow-hidden" }, op.name),
328
+ React.createElement("div", { className: "bg-[#FF8F45] text-white text-[12px] font-bold px-[14px] py-[4px] rounded-[6px] bold-text whitespace-nowrap" },
329
+ React.createElement("span", null, commonService.currencyRounded(op.price))),
330
+ React.createElement("span", { className: "bold-text text-[12px] text-white whitespace-nowrap" }, formatDepartureTime(op === null || op === void 0 ? void 0 : op.time)),
331
+ React.createElement("span", { className: "bold-text text-[10px] text-white whitespace-nowrap" }, op === null || op === void 0 ? void 0 : op.seatsAvailable)));
332
+ })),
305
333
  React.createElement("div", { className: "flex w-full items-center justify-center gap-[6px] text-[12px] rounded-full", style: {
306
334
  padding: "8px 14px",
307
335
  marginBottom: "6px",
@@ -335,10 +363,10 @@ const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOri
335
363
  animation: "pulse-zoom 2s ease-in-out infinite",
336
364
  whiteSpace: "nowrap",
337
365
  } },
338
- displaySavingsPercent,
366
+ React.createElement("span", { "data-feature-savings": serviceItem.id }, displaySavingsPercent),
339
367
  "% OFF"),
340
368
  React.createElement("span", { className: "text-[13.33px] font-normal leading-[20px] text-[#9f9f9f] relative", style: { position: "relative" } },
341
- commonService.currencyRounded(displayOriginalPrice * ticketQuantity),
369
+ React.createElement("span", { id: `feature-original-price-${serviceItem.id}` }, commonService.currencyRounded(displayOriginalPrice * ticketQuantity)),
342
370
  React.createElement("span", { style: {
343
371
  position: "absolute",
344
372
  left: "-2px",
@@ -349,7 +377,7 @@ const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOri
349
377
  transform: "rotate(-10deg)",
350
378
  transformOrigin: "center",
351
379
  } })),
352
- React.createElement("span", { className: "text-white bold-text text-[28px] leading-none" }, commonService.currencyRounded(displayFinalPrice * ticketQuantity))),
380
+ React.createElement("span", { id: `feature-final-price-${serviceItem.id}`, className: "text-white bold-text text-[28px] leading-none" }, commonService.currencyRounded(displayFinalPrice * ticketQuantity))),
353
381
  React.createElement("div", { className: "mt-[4px] flex flex-col items-center gap-[8px]" },
354
382
  React.createElement("span", { className: "text-[12px] text-white" }, "\u00BFCu\u00E1ntos pasajes quieres?"),
355
383
  React.createElement("div", { className: "flex w-full items-center justify-between rounded-[16px] ", style: {
@@ -374,6 +402,23 @@ const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOri
374
402
  btn.onclick = () => {
375
403
  if (btn.disabled)
376
404
  return;
405
+ // Require an operator card to be selected before booking
406
+ const currentCard = serviceCards.find((c) => c.key === selectedCardMap[serviceItem.id]);
407
+ if (!currentCard) {
408
+ selectErrorMap[serviceItem.id] = true;
409
+ const errorNode = document.getElementById(`feature-select-error-${serviceItem.id}`);
410
+ if (errorNode)
411
+ errorNode.style.display = "block";
412
+ return;
413
+ }
414
+ const chosenService = currentCard.service;
415
+ // Selected service first so consumers reading [0] get the chosen one
416
+ const orderedServices = chosenService
417
+ ? [
418
+ chosenService,
419
+ ...sortedServices.filter((s) => s !== chosenService),
420
+ ]
421
+ : sortedServices;
377
422
  btn.disabled = true;
378
423
  btn.style.cursor = "not-allowed";
379
424
  btn.style.opacity = "0.85";
@@ -383,7 +428,7 @@ const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOri
383
428
  normal.style.display = "none";
384
429
  if (loading)
385
430
  loading.style.display = "flex";
386
- onBookButtonPress === null || onBookButtonPress === void 0 ? void 0 : onBookButtonPress(ticketQuantity, serviceItem, servicesInActiveSlot);
431
+ onBookButtonPress === null || onBookButtonPress === void 0 ? void 0 : onBookButtonPress(ticketQuantity, serviceItem, orderedServices, chosenService);
387
432
  };
388
433
  }, className: "flex items-center gap-[6px] px-[20px] py-[10px] rounded-[16px] text-white bold-text text-[13px] mt-[4px] justify-center border-none cursor-pointer text-center", style: {
389
434
  backgroundColor: "#FF5C60",
@@ -403,7 +448,11 @@ const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOri
403
448
  borderRadius: "50%",
404
449
  animation: "feature-spin 0.7s linear infinite",
405
450
  flexShrink: 0,
406
- } })))),
451
+ } }))),
452
+ React.createElement("span", { id: `feature-select-error-${serviceItem.id}`, role: "alert", className: "text-[11px] text-[#FF5C60] text-center leading-[14px]", style: {
453
+ display: isSelectErrorVisible ? "block" : "none",
454
+ marginTop: "-4px",
455
+ } }, "Selecciona un horario para continuar")),
407
456
  React.createElement("div", { id: `feature-arrow-${serviceItem.id}`, className: `absolute bottom-[11px] right-[18px] cursor-pointer transition-transform duration-300 ease-in-out ${isItemExpanded ? "rotate-180" : ""}`, onClick: (e) => {
408
457
  expandedStateMap[serviceItem.id] =
409
458
  !expandedStateMap[serviceItem.id];
@@ -428,7 +477,7 @@ const FeatureServiceUi = ({ serviceItem, showTopLabel, getAnimationIcon, cityOri
428
477
  }
429
478
  }
430
479
  } },
431
- React.createElement("img", { src: (_e = serviceItem.icons) === null || _e === void 0 ? void 0 : _e.downArrow, alt: "down arrow", style: {
480
+ React.createElement("img", { src: (_d = serviceItem.icons) === null || _d === void 0 ? void 0 : _d.downArrow, alt: "down arrow", style: {
432
481
  width: "14px",
433
482
  height: "8px",
434
483
  filter: "brightness(0) invert(1)",
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ export declare const CARD_BORDER = "1px solid #363c48";
3
+ export declare const CARD_SELECTED_BORDER = "0.5px solid rgb(255, 92, 96)";
4
+ export declare const CARD_SELECTED_SHADOW = "inset 0 0 0 1px rgb(255, 92, 96)";
5
+ export declare const formatDepartureTime: (time?: string) => string;
6
+ export declare const formatTravelDate: (travelDate?: string) => string;
7
+ export declare const setNodeText: (node: Element | null, text: string) => void;
8
+ export declare const OriginIcon: ({ size }: {
9
+ size?: number;
10
+ }) => React.JSX.Element;
11
+ export declare const DestinationIcon: ({ size }: {
12
+ size?: number;
13
+ }) => React.JSX.Element;
14
+ export declare const CalendarIcon: ({ size }: {
15
+ size?: number;
16
+ }) => React.JSX.Element;
17
+ export declare const CrownIcon: ({ width, height, }: {
18
+ width?: number;
19
+ height?: number;
20
+ }) => React.JSX.Element;
@@ -0,0 +1,66 @@
1
+ import React from "react";
2
+ import DateService from "../../utils/DateService";
3
+ // Shared helpers for FeatureServiceUi (desktop) and FeatureServiceUiMobile.
4
+ if (typeof document !== "undefined") {
5
+ let style = document.getElementById("feature-spin-style");
6
+ if (!style) {
7
+ style = document.createElement("style");
8
+ style.id = "feature-spin-style";
9
+ document.head.appendChild(style);
10
+ }
11
+ // Always (re)write so an older injected version doesn't stick around
12
+ style.textContent =
13
+ "@keyframes feature-spin { to { transform: rotate(360deg); } }" +
14
+ // Cards stay scrollable, but the scrollbar takes no width and is hidden
15
+ ".feature-cards-scroll{scrollbar-width:none;-ms-overflow-style:none}" +
16
+ ".feature-cards-scroll::-webkit-scrollbar{display:none;width:0;height:0}";
17
+ }
18
+ export const CARD_BORDER = "1px solid #363c48";
19
+ export const CARD_SELECTED_BORDER = "0.5px solid rgb(255, 92, 96)";
20
+ export const CARD_SELECTED_SHADOW = "inset 0 0 0 1px rgb(255, 92, 96)";
21
+ // "07:00" / "19:30" -> "07:00 am" / "07:30 pm"; leaves other formats untouched
22
+ export const formatDepartureTime = (time) => {
23
+ if (!time)
24
+ return "";
25
+ const match = /^(\d{1,2}):(\d{2})$/.exec(String(time).trim());
26
+ if (!match)
27
+ return time;
28
+ const hours = parseInt(match[1], 10);
29
+ const hours12 = hours % 12 || 12;
30
+ const hh = hours12 < 10 ? `0${hours12}` : String(hours12);
31
+ return `${hh}:${match[2]} ${hours < 12 ? "am" : "pm"}`;
32
+ };
33
+ // "2026-05-23" -> "Vie. 23 de mayo"
34
+ export const formatTravelDate = (travelDate) => {
35
+ if (!travelDate)
36
+ return "Vie. 23 de mayo";
37
+ const dayName = DateService.getDayNameFromDate(travelDate, "yyyy-mm-dd", "half");
38
+ const day = parseInt(DateService.getDateFromDate(travelDate, "yyyy-mm-dd"), 10);
39
+ const monthName = DateService.getMonthNameFromDate(travelDate, "yyyy-mm-dd", "full");
40
+ return `${dayName.charAt(0).toUpperCase() + dayName.slice(1)}. ${day} de ${monthName}`;
41
+ };
42
+ // Update a node's text without replacing the text node React is tracking
43
+ export const setNodeText = (node, text) => {
44
+ if (!node)
45
+ return;
46
+ const first = node.firstChild;
47
+ if (first && first.nodeType === 3 && node.childNodes.length === 1) {
48
+ first.nodeValue = text;
49
+ }
50
+ else {
51
+ node.textContent = text;
52
+ }
53
+ };
54
+ export const OriginIcon = ({ size = 20 }) => (React.createElement("svg", { width: size, height: size, viewBox: "0 0 20 20", fill: "none" },
55
+ React.createElement("circle", { cx: "10", cy: "10", r: "8.5", stroke: "#FF5C60", strokeWidth: "1.6" }),
56
+ React.createElement("circle", { cx: "10", cy: "10", r: "4.5", stroke: "#FF5C60", strokeWidth: "1.6" })));
57
+ export const DestinationIcon = ({ size = 20 }) => (React.createElement("svg", { width: size, height: (size * 22) / 20, viewBox: "0 0 20 22", fill: "none" },
58
+ React.createElement("path", { d: "M10 20.5s-7-6.2-7-11.5a7 7 0 1 1 14 0c0 5.3-7 11.5-7 11.5z", stroke: "#FF5C60", strokeWidth: "1.6", strokeLinejoin: "round" }),
59
+ React.createElement("circle", { cx: "10", cy: "9", r: "3", stroke: "#FF5C60", strokeWidth: "1.6" })));
60
+ export const CalendarIcon = ({ size = 20 }) => (React.createElement("svg", { width: size, height: size, viewBox: "0 0 20 20", fill: "none" },
61
+ React.createElement("rect", { x: "2", y: "3.5", width: "16", height: "14.5", rx: "2", stroke: "#9b9b9b", strokeWidth: "1.4" }),
62
+ React.createElement("path", { d: "M2 7.5h16M6 2v3M14 2v3", stroke: "#9b9b9b", strokeWidth: "1.4", strokeLinecap: "round" }),
63
+ React.createElement("path", { d: "M6 11h.01M10 11h.01M14 11h.01M6 14.5h.01M10 14.5h.01M14 14.5h.01", stroke: "#9b9b9b", strokeWidth: "1.8", strokeLinecap: "round" })));
64
+ export const CrownIcon = ({ width = 18, height = 14, }) => (React.createElement("svg", { width: width, height: height, viewBox: "0 0 24 18", fill: "#fff" },
65
+ React.createElement("path", { d: "M2 5.5l5 4.2L12 2l5 7.7 5-4.2-2 9.5H4L2 5.5z" }),
66
+ React.createElement("rect", { x: "4", y: "16", width: "16", height: "2", rx: "1" })));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kupos-ui-components-lib",
3
- "version": "10.4.74",
3
+ "version": "10.4.76",
4
4
  "description": "A reusable UI components package",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -979,6 +979,9 @@ function PeruServiceItemDesktop({
979
979
  setIsExpand && setIsExpand(isItemExpanded ? null : serviceItem.id)
980
980
  }
981
981
  isPeru={isPeru}
982
+ availableSeats={
983
+ showAvailableSeats ? serviceItem?.available_seats : undefined
984
+ }
982
985
  />
983
986
  </div>
984
987
  </div>
@@ -791,6 +791,9 @@ function ServiceItemPB({
791
791
  showServiceTypeUnderLogo ? undefined : serviceItem.service_type
792
792
  }
793
793
  renderIcon={renderIcon}
794
+ availableSeats={
795
+ showAvailableSeats ? serviceItem?.available_seats : undefined
796
+ }
794
797
  />
795
798
  </div>
796
799
  </div>
@@ -33,6 +33,7 @@ interface BottomAmenitiesProps {
33
33
  isDpEnabled?: boolean;
34
34
  serviceType?: string;
35
35
  renderIcon?: (iconKey: string, size?: string) => React.ReactNode;
36
+ availableSeats?: number;
36
37
  }
37
38
 
38
39
  function BottomAmenities({
@@ -51,6 +52,7 @@ function BottomAmenities({
51
52
  isDpEnabled,
52
53
  serviceType,
53
54
  renderIcon,
55
+ availableSeats,
54
56
  }: BottomAmenitiesProps): React.ReactElement {
55
57
  const hasPetInfo =
56
58
  serviceItem.pet_seat_info &&
@@ -93,7 +95,16 @@ function BottomAmenities({
93
95
  </div>
94
96
 
95
97
  {/* PET + FLEXIBLE (always visible) */}
96
- <div className="flex items-center justify-end shrink-0 ml-[5px] w-[150px]">
98
+ <div className="flex items-center justify-end shrink-0 ml-[5px] min-w-[150px]">
99
+ {availableSeats !== undefined && availableSeats > 0 && !isSoldOut && (
100
+ <div
101
+ className="text-[13.33px] whitespace-nowrap mr-[10px]"
102
+ style={{ color:"#464647" }}
103
+ >
104
+ {availableSeats}{" "}
105
+ {availableSeats === 1 ? "asiento restante" : "asientos restantes"}
106
+ </div>
107
+ )}
97
108
  {serviceItem?.is_tracking_enabled && (
98
109
  <div className={grayscaleClass + " mr-[10px]"}>
99
110
  <LottiePlayer