kupos-ui-components-lib 10.4.75 → 10.4.77

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,127 @@ 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
+ // Crown stays on the lowest fare card — selection only moves the border
218
+ });
219
+
220
+ const card = serviceCards.find((c) => c.key === key);
221
+ const price = getPriceInfo(card?.service || sortedServices[0]);
222
+ setNodeText(
223
+ document.getElementById(`feature-m-final-price-${serviceItem.id}`),
224
+ commonService.currencyRounded(price.final * ticketQuantity),
225
+ );
226
+ setNodeText(
227
+ document.getElementById(`feature-m-original-price-${serviceItem.id}`),
228
+ commonService.currencyRounded(price.original * ticketQuantity),
229
+ );
230
+ document
231
+ .querySelectorAll(`[data-feature-m-savings="${serviceItem.id}"]`)
232
+ .forEach((node) => setNodeText(node, String(price.savings)));
233
+
234
+ const errorNode = document.getElementById(
235
+ `feature-m-select-error-${serviceItem.id}`,
236
+ );
237
+ if (errorNode) errorNode.style.display = "none";
238
+ };
133
239
 
134
240
  // The label shown on the dropdown button (24h format, e.g. "Entre 16:00 y 20:00")
135
241
  const departureRange =
136
242
  activeSlot?.label || `Entre ${dealWindowFrom} y ${dealWindowTo}`;
137
243
 
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;
244
+ // Time slot dropdown is disabled, so use the full deal window
245
+ const currentWindowFrom = dealWindowFrom;
246
+ const currentWindowTo = dealWindowTo;
247
+ // const currentWindowFrom = activeSlot
248
+ // ? commonService.minutesToTime(activeSlot.start)
249
+ // : dealWindowFrom;
250
+ // const currentWindowTo = activeSlot
251
+ // ? commonService.minutesToTime(
252
+ // activeSlot.end === 1440 ? 1439 : activeSlot.end,
253
+ // )
254
+ // : dealWindowTo;
146
255
 
147
256
  const isItemExpanded =
148
257
  serviceItem.id === isFeatureDropDownExpand ||
@@ -229,7 +338,13 @@ const FeatureServiceUiMobile = ({
229
338
  fontSize: "10px",
230
339
  }}
231
340
  >
232
- <span>AHORRAS {displaySavingsPercent}%</span>
341
+ <span>
342
+ AHORRAS{" "}
343
+ <span data-feature-m-savings={serviceItem.id}>
344
+ {displaySavingsPercent}
345
+ </span>
346
+ %
347
+ </span>
233
348
  </div>
234
349
  </div>
235
350
  </div>
@@ -239,10 +354,46 @@ const FeatureServiceUiMobile = ({
239
354
  >
240
355
  <div className="flex flex-col gap-[10px]">
241
356
  <div className=" text-[white]">
357
+ {/* Origin / destination / date, stacked (same as desktop) */}
358
+ <div
359
+ className="flex flex-col gap-[10px]"
360
+ style={{ filter: isSoldOut ? "grayscale(1)" : undefined }}
361
+ >
362
+ <div className="flex items-center gap-[10px]">
363
+ <OriginIcon size={16} />
364
+ <span className="text-[14px] bold-text">
365
+ {cityOrigin?.label.split(",")[0]}
366
+ </span>
367
+ </div>
368
+ <div className="flex items-center gap-[10px]">
369
+ <DestinationIcon size={16} />
370
+ <span className="text-[14px] bold-text">
371
+ {cityDestination?.label.split(",")[0]}
372
+ </span>
373
+ </div>
374
+ <div className="flex items-center gap-[10px] text-[13px]">
375
+ <CalendarIcon size={16} />
376
+ <span>{formatTravelDate(travelDate)}</span>
377
+ </div>
378
+ </div>
379
+
380
+ <div className="flex items-center gap-[10px] mt-[14px]">
381
+ <div className="shrink-0">
382
+ {renderIcon("sheildIcon", "20px")}
383
+ </div>
384
+ <div className="flex flex-col gap-[2px]">
385
+ <span className="bold-text text-[13px] text-white">
386
+ Precio Remate
387
+ </span>
388
+ <span className="text-[11px] text-[#dcdcdc]">
389
+ Sin cambios, sin cancelación
390
+ </span>
391
+ </div>
392
+ </div>
393
+
394
+ {/* Previous origin / destination row with the time slot dropdown — disabled, kept for reference
242
395
  <div className="flex flex-col gap-[6px] relative">
243
- {/* Origin / Destination row with connecting-line icons */}
244
396
  <div className="flex items-start gap-[10px]">
245
- {/* Icon column with connecting line */}
246
397
  <div
247
398
  className="flex flex-col items-center shrink-0"
248
399
  style={{ paddingTop: "2px" }}
@@ -268,7 +419,6 @@ const FeatureServiceUiMobile = ({
268
419
  style={{ opacity: isSoldOut ? 0.5 : 1 }}
269
420
  />
270
421
  </div>
271
- {/* Text column */}
272
422
  <div className="flex flex-col gap-[6px]">
273
423
  <div className="flex items-center gap-[6px]">
274
424
  <span className="text-[14px] bold-text">
@@ -331,7 +481,6 @@ const FeatureServiceUiMobile = ({
331
481
  const isActive =
332
482
  slot.label === selectedTimeSlot ||
333
483
  slot === activeSlot;
334
- // Count services in this slot
335
484
  const count = services.filter((s) => {
336
485
  const depMin = commonService.timeToMinutes(
337
486
  s.departure_time,
@@ -370,11 +519,9 @@ const FeatureServiceUiMobile = ({
370
519
  )}
371
520
  </div>
372
521
  </div>
373
- {/* end text column */}
374
522
  </div>
375
- {/* end flex items-start row */}
376
523
  </div>
377
- {/* end flex-col gap-6 relative */}
524
+ */}
378
525
  </div>
379
526
  {/* end text-[white] */}
380
527
 
@@ -382,46 +529,72 @@ const FeatureServiceUiMobile = ({
382
529
 
383
530
  <div>
384
531
  <span
385
- className="block w-full text-[14px] bold-text text-[white] mb-[10px]"
532
+ className="block w-full text-[14px] bold-text text-[white] mb-[2px]"
386
533
  style={{ textAlign: "center" }}
387
534
  >
388
- {servicesInActiveSlot.length > 0
389
- ? servicesInActiveSlot.length
535
+ {sortedServices.length > 0
536
+ ? sortedServices.length
390
537
  : operatorsCompetingCount}{" "}
391
- operadores compitiendo <br />
538
+ ofertas compitiendo <br />
392
539
  por tu compra
393
540
  </span>
394
- {/* Operator boxes: single full-width box when 1 operator, else flex row */}
541
+ {/* Operator cards: cheapest first, 3 visible, the rest scroll */}
395
542
  <div
396
- className="flex gap-[8px] text-[white]"
397
- style={{ width: "100%", justifyContent: "center" }}
543
+ id={`feature-m-cards-${serviceItem.id}`}
544
+ className="feature-cards-scroll flex w-full items-stretch gap-[8px] overflow-x-auto text-[white]"
545
+ style={{
546
+ justifyContent: hasCardScroll ? "flex-start" : "center",
547
+ // room for the crown badge that sits above the selected card
548
+ paddingTop: "12px",
549
+ scrollbarWidth: "none",
550
+ }}
398
551
  >
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) => (
552
+ {serviceCards.map((op, idx) => {
553
+ const isSelected = op.key === selectedCard?.key;
554
+ // Cards are sorted cheapest first, so index 0 is the lowest fare
555
+ const isLowestFare = idx === 0;
556
+ return (
411
557
  <div
412
- key={idx}
413
- className="flex min-w-0 flex-col items-center justify-center gap-[8px] rounded-[8px]"
558
+ key={op.key}
559
+ data-card-key={op.key}
560
+ role="button"
561
+ tabIndex={0}
562
+ aria-pressed={isSelected}
563
+ onClick={() => handleCardSelect(op.key)}
564
+ onKeyDown={(e) => {
565
+ if (e.key === "Enter" || e.key === " ") {
566
+ e.preventDefault();
567
+ handleCardSelect(op.key);
568
+ }
569
+ }}
570
+ className="relative flex min-w-0 cursor-pointer flex-col items-center justify-center gap-[6px] outline-none"
414
571
  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",
572
+ flex: "0 0 calc((100% - 16px) / 3)",
573
+ minWidth: "90px",
574
+ border: isSelected ? CARD_SELECTED_BORDER : CARD_BORDER,
575
+ boxShadow: isSelected ? CARD_SELECTED_SHADOW : "none",
421
576
  backgroundColor: "#1a202e",
422
- padding: "14px 6px",
577
+ padding: "16px 6px 12px",
578
+ borderRadius: "10px",
579
+ transition:
580
+ "border-color 150ms ease, box-shadow 150ms ease",
423
581
  }}
424
582
  >
583
+ {/* Crown badge — always on the lowest fare card */}
584
+ <span
585
+ className="absolute items-center justify-center"
586
+ style={{
587
+ display: isLowestFare ? "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]"