kupos-ui-components-lib 10.4.75 → 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.
@@ -2,6 +2,18 @@ import React from "react";
2
2
  import LottiePlayer from "../../assets/LottiePlayer";
3
3
  import commonService from "../../utils/CommonService";
4
4
  import flameAnimation from "../../assets/images/anims/service_list/flame_anim.json";
5
+ import {
6
+ CARD_BORDER,
7
+ CARD_SELECTED_BORDER,
8
+ CARD_SELECTED_SHADOW,
9
+ CalendarIcon,
10
+ CrownIcon,
11
+ DestinationIcon,
12
+ OriginIcon,
13
+ formatDepartureTime,
14
+ formatTravelDate,
15
+ setNodeText,
16
+ } from "../FeatureServiceUI/featureServiceShared";
5
17
 
6
18
  const HARDCODED_OPERATORS = [
7
19
  {
@@ -24,6 +36,11 @@ const HARDCODED_OPERATORS = [
24
36
  },
25
37
  ];
26
38
 
39
+ // Selected operator card per serviceItem.id (no hooks — module-level state)
40
+ const selectedCardMap: Record<string, string | null> = {};
41
+ // Whether the "select a card" error is visible per serviceItem.id
42
+ const selectErrorMap: Record<string, boolean> = {};
43
+
27
44
  const FeatureServiceUiMobile = ({
28
45
  serviceItem,
29
46
  showTopLabel,
@@ -104,6 +121,7 @@ const FeatureServiceUiMobile = ({
104
121
  availableTimeSlots[0];
105
122
 
106
123
  // Services that fall within the active slot, sorted by final price ascending
124
+ // (only used by the disabled time slot dropdown)
107
125
  const servicesInActiveSlot = activeSlot
108
126
  ? services
109
127
  .filter((s) => {
@@ -113,36 +131,128 @@ const FeatureServiceUiMobile = ({
113
131
  .sort((a, b) => a.final_price - b.final_price)
114
132
  : services;
115
133
 
116
- // The selected price (final and original) from the cheapest service in the active slot
117
- const selectedSlotService = servicesInActiveSlot?.[0];
118
- const displayFinalPrice = selectedSlotService
119
- ? selectedSlotService.final_price
120
- : finalPrice;
121
- const displayOriginalPrice = selectedSlotService
122
- ? selectedSlotService.original_price
123
- : originalPrice;
124
- const displaySavingsPercent =
125
- selectedSlotService && selectedSlotService.original_price
126
- ? Math.round(
127
- ((selectedSlotService.original_price -
128
- selectedSlotService.final_price) /
129
- selectedSlotService.original_price) *
130
- 100,
131
- )
132
- : savingsPercent;
134
+ // All services in the deal window (time slot dropdown is disabled),
135
+ // cheapest first, then by departure time.
136
+ const sortedServices = [...services].sort(
137
+ (a, b) =>
138
+ (a.final_price ?? 0) - (b.final_price ?? 0) ||
139
+ commonService.timeToMinutes(a.departure_time) -
140
+ commonService.timeToMinutes(b.departure_time),
141
+ );
142
+
143
+ // Cards — 3 visible, the rest scroll horizontally (scrollbar hidden)
144
+ const serviceCards: any[] =
145
+ sortedServices.length > 0
146
+ ? sortedServices.map((s: any, idx: number) => ({
147
+ key: String(
148
+ s?.id ??
149
+ s?.service_id ??
150
+ `${s?.operator_name}-${s?.departure_time}-${idx}`,
151
+ ),
152
+ logo: s.operator_logo_url,
153
+ name: s.operator_name,
154
+ time: s.departure_time,
155
+ price: s.final_price,
156
+ seatsAvailable: `¡Solo quedan ${s.available_seats}!`,
157
+ service: s,
158
+ }))
159
+ : operators.map((op: any, idx: number) => ({
160
+ ...op,
161
+ key: `fallback-${idx}`,
162
+ price: op.price ?? finalPrice,
163
+ service: null,
164
+ }));
165
+ const hasCardScroll = serviceCards.length > 3;
166
+
167
+ // Default to the first (cheapest) card when nothing is selected yet,
168
+ // or when the previous selection is no longer in the list (data refresh)
169
+ if (
170
+ serviceCards.length > 0 &&
171
+ !serviceCards.some((c) => c.key === selectedCardMap[serviceItem.id])
172
+ ) {
173
+ selectedCardMap[serviceItem.id] = serviceCards[0].key;
174
+ }
175
+ const selectedCard = serviceCards.find(
176
+ (c) => c.key === selectedCardMap[serviceItem.id],
177
+ );
178
+ const isSelectErrorVisible =
179
+ !selectedCard && !!selectErrorMap[serviceItem.id];
180
+
181
+ const getPriceInfo = (svc: any) =>
182
+ svc
183
+ ? {
184
+ final: svc.final_price,
185
+ original: svc.original_price,
186
+ savings: svc.original_price
187
+ ? Math.round(
188
+ ((svc.original_price - svc.final_price) / svc.original_price) *
189
+ 100,
190
+ )
191
+ : savingsPercent,
192
+ }
193
+ : { final: finalPrice, original: originalPrice, savings: savingsPercent };
194
+
195
+ // Price shown: the selected card's service, otherwise the cheapest service
196
+ const selectedSlotService = selectedCard?.service || sortedServices[0];
197
+ const {
198
+ final: displayFinalPrice,
199
+ original: displayOriginalPrice,
200
+ savings: displaySavingsPercent,
201
+ } = getPriceInfo(selectedSlotService);
202
+
203
+ const handleCardSelect = (key: string) => {
204
+ selectedCardMap[serviceItem.id] = key;
205
+ selectErrorMap[serviceItem.id] = false;
206
+ if (typeof document === "undefined") return;
207
+
208
+ const container = document.getElementById(
209
+ `feature-m-cards-${serviceItem.id}`,
210
+ );
211
+ container
212
+ ?.querySelectorAll<HTMLElement>("[data-card-key]")
213
+ .forEach((node) => {
214
+ const isSelected = node.dataset.cardKey === key;
215
+ node.style.border = isSelected ? CARD_SELECTED_BORDER : CARD_BORDER;
216
+ node.style.boxShadow = isSelected ? CARD_SELECTED_SHADOW : "none";
217
+ const check = node.querySelector<HTMLElement>("[data-card-check]");
218
+ if (check) check.style.display = isSelected ? "flex" : "none";
219
+ });
220
+
221
+ const card = serviceCards.find((c) => c.key === key);
222
+ const price = getPriceInfo(card?.service || sortedServices[0]);
223
+ setNodeText(
224
+ document.getElementById(`feature-m-final-price-${serviceItem.id}`),
225
+ commonService.currencyRounded(price.final * ticketQuantity),
226
+ );
227
+ setNodeText(
228
+ document.getElementById(`feature-m-original-price-${serviceItem.id}`),
229
+ commonService.currencyRounded(price.original * ticketQuantity),
230
+ );
231
+ document
232
+ .querySelectorAll(`[data-feature-m-savings="${serviceItem.id}"]`)
233
+ .forEach((node) => setNodeText(node, String(price.savings)));
234
+
235
+ const errorNode = document.getElementById(
236
+ `feature-m-select-error-${serviceItem.id}`,
237
+ );
238
+ if (errorNode) errorNode.style.display = "none";
239
+ };
133
240
 
134
241
  // The label shown on the dropdown button (24h format, e.g. "Entre 16:00 y 20:00")
135
242
  const departureRange =
136
243
  activeSlot?.label || `Entre ${dealWindowFrom} y ${dealWindowTo}`;
137
244
 
138
- const currentWindowFrom = activeSlot
139
- ? commonService.minutesToTime(activeSlot.start)
140
- : dealWindowFrom;
141
- const currentWindowTo = activeSlot
142
- ? commonService.minutesToTime(
143
- activeSlot.end === 1440 ? 1439 : activeSlot.end,
144
- )
145
- : dealWindowTo;
245
+ // Time slot dropdown is disabled, so use the full deal window
246
+ const currentWindowFrom = dealWindowFrom;
247
+ const currentWindowTo = dealWindowTo;
248
+ // const currentWindowFrom = activeSlot
249
+ // ? commonService.minutesToTime(activeSlot.start)
250
+ // : dealWindowFrom;
251
+ // const currentWindowTo = activeSlot
252
+ // ? commonService.minutesToTime(
253
+ // activeSlot.end === 1440 ? 1439 : activeSlot.end,
254
+ // )
255
+ // : dealWindowTo;
146
256
 
147
257
  const isItemExpanded =
148
258
  serviceItem.id === isFeatureDropDownExpand ||
@@ -229,7 +339,13 @@ const FeatureServiceUiMobile = ({
229
339
  fontSize: "10px",
230
340
  }}
231
341
  >
232
- <span>AHORRAS {displaySavingsPercent}%</span>
342
+ <span>
343
+ AHORRAS{" "}
344
+ <span data-feature-m-savings={serviceItem.id}>
345
+ {displaySavingsPercent}
346
+ </span>
347
+ %
348
+ </span>
233
349
  </div>
234
350
  </div>
235
351
  </div>
@@ -239,10 +355,46 @@ const FeatureServiceUiMobile = ({
239
355
  >
240
356
  <div className="flex flex-col gap-[10px]">
241
357
  <div className=" text-[white]">
358
+ {/* Origin / destination / date, stacked (same as desktop) */}
359
+ <div
360
+ className="flex flex-col gap-[10px]"
361
+ style={{ filter: isSoldOut ? "grayscale(1)" : undefined }}
362
+ >
363
+ <div className="flex items-center gap-[10px]">
364
+ <OriginIcon size={16} />
365
+ <span className="text-[14px] bold-text">
366
+ {cityOrigin?.label.split(",")[0]}
367
+ </span>
368
+ </div>
369
+ <div className="flex items-center gap-[10px]">
370
+ <DestinationIcon size={16} />
371
+ <span className="text-[14px] bold-text">
372
+ {cityDestination?.label.split(",")[0]}
373
+ </span>
374
+ </div>
375
+ <div className="flex items-center gap-[10px] text-[13px]">
376
+ <CalendarIcon size={16} />
377
+ <span>{formatTravelDate(travelDate)}</span>
378
+ </div>
379
+ </div>
380
+
381
+ <div className="flex items-center gap-[10px] mt-[14px]">
382
+ <div className="shrink-0">
383
+ {renderIcon("sheildIcon", "20px")}
384
+ </div>
385
+ <div className="flex flex-col gap-[2px]">
386
+ <span className="bold-text text-[13px] text-white">
387
+ Precio Remate
388
+ </span>
389
+ <span className="text-[11px] text-[#dcdcdc]">
390
+ Sin cambios, sin cancelación
391
+ </span>
392
+ </div>
393
+ </div>
394
+
395
+ {/* Previous origin / destination row with the time slot dropdown — disabled, kept for reference
242
396
  <div className="flex flex-col gap-[6px] relative">
243
- {/* Origin / Destination row with connecting-line icons */}
244
397
  <div className="flex items-start gap-[10px]">
245
- {/* Icon column with connecting line */}
246
398
  <div
247
399
  className="flex flex-col items-center shrink-0"
248
400
  style={{ paddingTop: "2px" }}
@@ -268,7 +420,6 @@ const FeatureServiceUiMobile = ({
268
420
  style={{ opacity: isSoldOut ? 0.5 : 1 }}
269
421
  />
270
422
  </div>
271
- {/* Text column */}
272
423
  <div className="flex flex-col gap-[6px]">
273
424
  <div className="flex items-center gap-[6px]">
274
425
  <span className="text-[14px] bold-text">
@@ -331,7 +482,6 @@ const FeatureServiceUiMobile = ({
331
482
  const isActive =
332
483
  slot.label === selectedTimeSlot ||
333
484
  slot === activeSlot;
334
- // Count services in this slot
335
485
  const count = services.filter((s) => {
336
486
  const depMin = commonService.timeToMinutes(
337
487
  s.departure_time,
@@ -370,11 +520,9 @@ const FeatureServiceUiMobile = ({
370
520
  )}
371
521
  </div>
372
522
  </div>
373
- {/* end text column */}
374
523
  </div>
375
- {/* end flex items-start row */}
376
524
  </div>
377
- {/* end flex-col gap-6 relative */}
525
+ */}
378
526
  </div>
379
527
  {/* end text-[white] */}
380
528
 
@@ -382,46 +530,71 @@ const FeatureServiceUiMobile = ({
382
530
 
383
531
  <div>
384
532
  <span
385
- className="block w-full text-[14px] bold-text text-[white] mb-[10px]"
533
+ className="block w-full text-[14px] bold-text text-[white] mb-[2px]"
386
534
  style={{ textAlign: "center" }}
387
535
  >
388
- {servicesInActiveSlot.length > 0
389
- ? servicesInActiveSlot.length
536
+ {sortedServices.length > 0
537
+ ? sortedServices.length
390
538
  : operatorsCompetingCount}{" "}
391
- operadores compitiendo <br />
539
+ ofertas compitiendo <br />
392
540
  por tu compra
393
541
  </span>
394
- {/* Operator boxes: single full-width box when 1 operator, else flex row */}
542
+ {/* Operator cards: cheapest first, 3 visible, the rest scroll */}
395
543
  <div
396
- className="flex gap-[8px] text-[white]"
397
- style={{ width: "100%", justifyContent: "center" }}
544
+ id={`feature-m-cards-${serviceItem.id}`}
545
+ className="feature-cards-scroll flex w-full items-stretch gap-[8px] overflow-x-auto text-[white]"
546
+ style={{
547
+ justifyContent: hasCardScroll ? "flex-start" : "center",
548
+ // room for the crown badge that sits above the selected card
549
+ paddingTop: "12px",
550
+ scrollbarWidth: "none",
551
+ }}
398
552
  >
399
- {(() => {
400
- const displayOps =
401
- servicesInActiveSlot.length > 0
402
- ? servicesInActiveSlot.slice(0, 3).map((s: any) => ({
403
- logo: s.operator_logo_url,
404
- name: s.operator_name,
405
- time: s.departure_time,
406
- seatsAvailable: `${s.available_seats} disponibles`,
407
- }))
408
- : operators;
409
- const isSingle = displayOps.length === 1;
410
- return displayOps.map((op, idx) => (
553
+ {serviceCards.map((op) => {
554
+ const isSelected = op.key === selectedCard?.key;
555
+ return (
411
556
  <div
412
- key={idx}
413
- className="flex min-w-0 flex-col items-center justify-center gap-[8px] rounded-[8px]"
557
+ key={op.key}
558
+ data-card-key={op.key}
559
+ role="button"
560
+ tabIndex={0}
561
+ aria-pressed={isSelected}
562
+ onClick={() => handleCardSelect(op.key)}
563
+ onKeyDown={(e) => {
564
+ if (e.key === "Enter" || e.key === " ") {
565
+ e.preventDefault();
566
+ handleCardSelect(op.key);
567
+ }
568
+ }}
569
+ className="relative flex min-w-0 cursor-pointer flex-col items-center justify-center gap-[6px] outline-none"
414
570
  style={{
415
- flex: isSingle ? "none" : 1,
416
- width: isSingle ? "32%" : undefined,
417
- margin: isSingle ? "0 auto" : undefined,
418
- minWidth: 0,
419
- height: "100px",
420
- border: "1px solid #363c48",
571
+ flex: "0 0 calc((100% - 16px) / 3)",
572
+ minWidth: "90px",
573
+ border: isSelected ? CARD_SELECTED_BORDER : CARD_BORDER,
574
+ boxShadow: isSelected ? CARD_SELECTED_SHADOW : "none",
421
575
  backgroundColor: "#1a202e",
422
- padding: "14px 6px",
576
+ padding: "16px 6px 12px",
577
+ borderRadius: "10px",
578
+ transition:
579
+ "border-color 150ms ease, box-shadow 150ms ease",
423
580
  }}
424
581
  >
582
+ {/* Crown badge on the selected card */}
583
+ <span
584
+ data-card-check="true"
585
+ className="absolute items-center justify-center"
586
+ style={{
587
+ display: isSelected ? "flex" : "none",
588
+ top: "-12px",
589
+ right: "-1px",
590
+ width: "30px",
591
+ height: "20px",
592
+ borderRadius: "6px",
593
+ backgroundColor: "#FF5C60",
594
+ }}
595
+ >
596
+ <CrownIcon width={14} height={11} />
597
+ </span>
425
598
  <img
426
599
  src={op.logo}
427
600
  alt={op.name}
@@ -441,12 +614,18 @@ const FeatureServiceUiMobile = ({
441
614
  <span className="text-[12px] truncate max-w-full text-center">
442
615
  {op.name}
443
616
  </span>
444
- <span className="text-[11px] whitespace-nowrap">
617
+ <div className="bg-[#FF8F45] text-white text-[12px] font-bold px-[10px] py-[3px] rounded-[6px] bold-text whitespace-nowrap">
618
+ {commonService.currencyRounded(op.price)}
619
+ </div>
620
+ <span className="bold-text text-[12px] whitespace-nowrap">
621
+ {formatDepartureTime(op?.time)}
622
+ </span>
623
+ <span className="bold-text text-[11px] whitespace-nowrap">
445
624
  {op?.seatsAvailable}
446
625
  </span>
447
626
  </div>
448
- ));
449
- })()}
627
+ );
628
+ })}
450
629
  </div>
451
630
 
452
631
  <div
@@ -551,9 +730,11 @@ const FeatureServiceUiMobile = ({
551
730
  className="text-[18px] font-normal leading-[20px] text-[#9f9f9f] relative"
552
731
  style={{ position: "relative" }}
553
732
  >
554
- {commonService.currencyRounded(
555
- displayOriginalPrice * ticketQuantity,
556
- )}
733
+ <span id={`feature-m-original-price-${serviceItem.id}`}>
734
+ {commonService.currencyRounded(
735
+ displayOriginalPrice * ticketQuantity,
736
+ )}
737
+ </span>
557
738
  <span
558
739
  style={{
559
740
  position: "absolute",
@@ -569,7 +750,10 @@ const FeatureServiceUiMobile = ({
569
750
  }}
570
751
  />
571
752
  </span>
572
- <span className="text-[white] bold-text text-[24px] leading-none mt-[4px]">
753
+ <span
754
+ id={`feature-m-final-price-${serviceItem.id}`}
755
+ className="text-[white] bold-text text-[24px] leading-none mt-[4px]"
756
+ >
573
757
  {commonService.currencyRounded(
574
758
  displayFinalPrice * ticketQuantity,
575
759
  )}
@@ -582,7 +766,10 @@ const FeatureServiceUiMobile = ({
582
766
  whiteSpace: "nowrap",
583
767
  }}
584
768
  >
585
- {displaySavingsPercent}% OFF
769
+ <span data-feature-m-savings={serviceItem.id}>
770
+ {displaySavingsPercent}
771
+ </span>
772
+ % OFF
586
773
  </span>
587
774
  </div>
588
775
 
@@ -592,6 +779,28 @@ const FeatureServiceUiMobile = ({
592
779
  if (!btn) return;
593
780
  btn.onclick = () => {
594
781
  if (btn.disabled) return;
782
+
783
+ // Require an operator card to be selected before booking
784
+ const currentCard = serviceCards.find(
785
+ (c) => c.key === selectedCardMap[serviceItem.id],
786
+ );
787
+ if (!currentCard) {
788
+ selectErrorMap[serviceItem.id] = true;
789
+ const errorNode = document.getElementById(
790
+ `feature-m-select-error-${serviceItem.id}`,
791
+ );
792
+ if (errorNode) errorNode.style.display = "block";
793
+ return;
794
+ }
795
+ const chosenService = currentCard.service;
796
+ // Selected service first so consumers reading [0] get the chosen one
797
+ const orderedServices = chosenService
798
+ ? [
799
+ chosenService,
800
+ ...sortedServices.filter((s) => s !== chosenService),
801
+ ]
802
+ : sortedServices;
803
+
595
804
  btn.disabled = true;
596
805
  btn.style.cursor = "not-allowed";
597
806
  btn.style.opacity = "0.85";
@@ -606,7 +815,8 @@ const FeatureServiceUiMobile = ({
606
815
  onBookButtonPress?.(
607
816
  ticketQuantity,
608
817
  serviceItem,
609
- servicesInActiveSlot,
818
+ orderedServices,
819
+ chosenService,
610
820
  );
611
821
  };
612
822
  }}
@@ -624,6 +834,16 @@ const FeatureServiceUiMobile = ({
624
834
  />
625
835
  <span className="whitespace-nowrap">¡Lo quiero!</span>
626
836
  </button>
837
+ <span
838
+ id={`feature-m-select-error-${serviceItem.id}`}
839
+ role="alert"
840
+ className="text-[11px] text-[#FF5C60] text-center leading-[14px] mt-[6px]"
841
+ style={{
842
+ display: isSelectErrorVisible ? "block" : "none",
843
+ }}
844
+ >
845
+ Selecciona un horario para continuar
846
+ </span>
627
847
 
628
848
  <div
629
849
  className="flex justify-end mt-[10px]"