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,17 +1,18 @@
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
-
6
- if (typeof document !== "undefined") {
7
- if (!document.getElementById("feature-spin-style")) {
8
- const style = document.createElement("style");
9
- style.id = "feature-spin-style";
10
- style.textContent =
11
- "@keyframes feature-spin { to { transform: rotate(360deg); } }";
12
- document.head.appendChild(style);
13
- }
14
- }
4
+ import {
5
+ CARD_BORDER,
6
+ CARD_SELECTED_BORDER,
7
+ CARD_SELECTED_SHADOW,
8
+ CalendarIcon,
9
+ CrownIcon,
10
+ DestinationIcon,
11
+ OriginIcon,
12
+ formatDepartureTime,
13
+ formatTravelDate,
14
+ setNodeText,
15
+ } from "./featureServiceShared";
15
16
 
16
17
  const HARDCODED_OPERATORS = [
17
18
  {
@@ -34,6 +35,10 @@ const HARDCODED_OPERATORS = [
34
35
  },
35
36
  ];
36
37
  const expandedStateMap: Record<string, boolean> = {};
38
+ // Selected operator card per serviceItem.id (no hooks — same pattern as expandedStateMap)
39
+ const selectedCardMap: Record<string, string | null> = {};
40
+ // Whether the "select a card" error is visible per serviceItem.id
41
+ const selectErrorMap: Record<string, boolean> = {};
37
42
 
38
43
  const FeatureServiceUi = ({
39
44
  serviceItem,
@@ -55,6 +60,7 @@ const FeatureServiceUi = ({
55
60
  onTimeDropdownToggle,
56
61
  wowDealData = undefined,
57
62
  }) => {
63
+ console.log("🚀 ~ FeatureServiceUi ~ wowDealData:", wowDealData);
58
64
  // Use wow_deal services if available, otherwise fall back to serviceItem operators or hardcoded
59
65
  const operators =
60
66
  wowDealData?.services?.length > 0
@@ -123,23 +129,112 @@ const FeatureServiceUi = ({
123
129
  .sort((a, b) => a.final_price - b.final_price)
124
130
  : services;
125
131
 
126
- // The selected price (final and original) from the cheapest service in the active slot
127
- const selectedSlotService = servicesInActiveSlot?.[0];
128
- const displayFinalPrice = selectedSlotService
129
- ? selectedSlotService.final_price
130
- : finalPrice;
131
- const displayOriginalPrice = selectedSlotService
132
- ? selectedSlotService.original_price
133
- : originalPrice;
134
- const displaySavingsPercent =
135
- selectedSlotService && selectedSlotService.original_price
136
- ? Math.round(
137
- ((selectedSlotService.original_price -
138
- selectedSlotService.final_price) /
139
- selectedSlotService.original_price) *
140
- 100,
141
- )
142
- : savingsPercent;
132
+ // All services in the deal window (time slot dropdown is disabled),
133
+ // cheapest first, then by departure time.
134
+ const sortedServices = [...services].sort(
135
+ (a, b) =>
136
+ (a.final_price ?? 0) - (b.final_price ?? 0) ||
137
+ commonService.timeToMinutes(a.departure_time) -
138
+ commonService.timeToMinutes(b.departure_time),
139
+ );
140
+
141
+ // Cards shown in the middle column — scrollable when there are more than 3
142
+ const serviceCards: any[] =
143
+ sortedServices.length > 0
144
+ ? sortedServices.map((s: any, idx: number) => ({
145
+ key: String(
146
+ s?.id ??
147
+ s?.service_id ??
148
+ `${s?.operator_name}-${s?.departure_time}-${idx}`,
149
+ ),
150
+ logo: s.operator_logo_url,
151
+ name: s.operator_name,
152
+ time: s.departure_time,
153
+ price: s.final_price,
154
+ seatsAvailable: `¡Solo quedan ${s.available_seats}!`,
155
+ service: s,
156
+ }))
157
+ : operators.map((op: any, idx: number) => ({
158
+ ...op,
159
+ key: `fallback-${idx}`,
160
+ price: op.price ?? finalPrice,
161
+ service: null,
162
+ }));
163
+ const hasCardScroll = serviceCards.length > 3;
164
+
165
+ // Default to the first (cheapest) card when nothing is selected yet,
166
+ // or when the previous selection is no longer in the list (data refresh)
167
+ if (
168
+ serviceCards.length > 0 &&
169
+ !serviceCards.some((c) => c.key === selectedCardMap[serviceItem.id])
170
+ ) {
171
+ selectedCardMap[serviceItem.id] = serviceCards[0].key;
172
+ }
173
+ const selectedCard = serviceCards.find(
174
+ (c) => c.key === selectedCardMap[serviceItem.id],
175
+ );
176
+ const isSelectErrorVisible =
177
+ !selectedCard && !!selectErrorMap[serviceItem.id];
178
+
179
+ const getPriceInfo = (svc: any) =>
180
+ svc
181
+ ? {
182
+ final: svc.final_price,
183
+ original: svc.original_price,
184
+ savings: svc.original_price
185
+ ? Math.round(
186
+ ((svc.original_price - svc.final_price) / svc.original_price) *
187
+ 100,
188
+ )
189
+ : savingsPercent,
190
+ }
191
+ : { final: finalPrice, original: originalPrice, savings: savingsPercent };
192
+
193
+ // Price shown: the selected card's service, otherwise the cheapest service
194
+ const selectedSlotService = selectedCard?.service || sortedServices[0];
195
+ const {
196
+ final: displayFinalPrice,
197
+ original: displayOriginalPrice,
198
+ savings: displaySavingsPercent,
199
+ } = getPriceInfo(selectedSlotService);
200
+
201
+ const handleCardSelect = (key: string) => {
202
+ selectedCardMap[serviceItem.id] = key;
203
+ selectErrorMap[serviceItem.id] = false;
204
+ if (typeof document === "undefined") return;
205
+
206
+ const container = document.getElementById(
207
+ `feature-cards-${serviceItem.id}`,
208
+ );
209
+ container
210
+ ?.querySelectorAll<HTMLElement>("[data-card-key]")
211
+ .forEach((node) => {
212
+ const isSelected = node.dataset.cardKey === key;
213
+ node.style.border = isSelected ? CARD_SELECTED_BORDER : CARD_BORDER;
214
+ node.style.boxShadow = isSelected ? CARD_SELECTED_SHADOW : "none";
215
+ const check = node.querySelector<HTMLElement>("[data-card-check]");
216
+ if (check) check.style.display = isSelected ? "flex" : "none";
217
+ });
218
+
219
+ const card = serviceCards.find((c) => c.key === key);
220
+ const price = getPriceInfo(card?.service || sortedServices[0]);
221
+ setNodeText(
222
+ document.getElementById(`feature-final-price-${serviceItem.id}`),
223
+ commonService.currencyRounded(price.final * ticketQuantity),
224
+ );
225
+ setNodeText(
226
+ document.getElementById(`feature-original-price-${serviceItem.id}`),
227
+ commonService.currencyRounded(price.original * ticketQuantity),
228
+ );
229
+ document
230
+ .querySelectorAll(`[data-feature-savings="${serviceItem.id}"]`)
231
+ .forEach((node) => setNodeText(node, String(price.savings)));
232
+
233
+ const errorNode = document.getElementById(
234
+ `feature-select-error-${serviceItem.id}`,
235
+ );
236
+ if (errorNode) errorNode.style.display = "none";
237
+ };
143
238
 
144
239
  // The label shown on the dropdown button
145
240
 
@@ -147,14 +242,17 @@ const FeatureServiceUi = ({
147
242
  activeSlot?.label || `${dealWindowFrom} y ${dealWindowTo}`;
148
243
  // activeSlot?.label || `Entre ${dealWindowFrom} y ${dealWindowTo}`;
149
244
 
150
- const currentWindowFrom = activeSlot
151
- ? commonService.minutesToTime(activeSlot.start)
152
- : dealWindowFrom;
153
- const currentWindowTo = activeSlot
154
- ? commonService.minutesToTime(
155
- activeSlot.end === 1440 ? 1439 : activeSlot.end,
156
- )
157
- : 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;
158
256
 
159
257
  if (expandedStateMap[serviceItem.id] === undefined) {
160
258
  expandedStateMap[serviceItem.id] = true;
@@ -241,7 +339,13 @@ const FeatureServiceUi = ({
241
339
  fontSize: "12px",
242
340
  }}
243
341
  >
244
- <span>AHORRAS {displaySavingsPercent}%</span>
342
+ <span>
343
+ AHORRAS{" "}
344
+ <span data-feature-savings={serviceItem.id}>
345
+ {displaySavingsPercent}
346
+ </span>
347
+ %
348
+ </span>
245
349
  </div>
246
350
  </div>
247
351
  <div
@@ -279,8 +383,23 @@ const FeatureServiceUi = ({
279
383
  >
280
384
  <div className="grid grid-cols-[27%_46%_27%] items-stretch">
281
385
  {/* LEFT: origin, destination, flexible, time, confirmed seat */}
282
- <div className="flex flex-col justify-between gap-[20px] mb-[16px] pr-[22px]">
283
- <div className="flex items-center gap-[6px]">
386
+ <div className="flex flex-col gap-[14px] mb-[16px] pr-[22px]">
387
+ {/* mt-auto here + mt-auto on "Precio Remate" centers origin/destination/date vertically */}
388
+ <div className="mt-auto flex flex-col gap-[12px]">
389
+ <div className="flex items-center gap-[12px]">
390
+ <OriginIcon />
391
+ <span className="text-[16px] bold-text text-white tracking-wide">
392
+ {cityOrigin?.label.split(",")[0]}
393
+ </span>
394
+ </div>
395
+ <div className="flex items-center gap-[12px]">
396
+ <DestinationIcon />
397
+ <span className="text-[16px] bold-text text-white tracking-wide">
398
+ {cityDestination?.label.split(",")[0]}
399
+ </span>
400
+ </div>
401
+ </div>
402
+ {/* <div className="flex items-center gap-[6px]">
284
403
  <span className="text-[16px] bold-text text-white tracking-wide">
285
404
  {cityOrigin?.label.split(",")[0]}
286
405
  </span>
@@ -288,9 +407,9 @@ const FeatureServiceUi = ({
288
407
  <span className="text-[16px] bold-text text-white tracking-wide">
289
408
  {cityDestination?.label.split(",")[0]}
290
409
  </span>
291
- </div>
410
+ </div> */}
292
411
 
293
- <div
412
+ {/* <div
294
413
  className="flex items-center w-fit rounded-[6px] px-[8px] py-[4px] gap-[6px] "
295
414
  style={{
296
415
  border: "1px solid rgb(54, 60, 72)",
@@ -301,9 +420,14 @@ const FeatureServiceUi = ({
301
420
  {renderIcon("flexibleIcon", "14px")}
302
421
  </div>
303
422
  <span className="text-[12px] text-white">Salida flexible</span>
304
- </div>
423
+ </div> */}
305
424
 
306
- <div className="flex flex-col gap-[4px] mt-[4px]">
425
+ <div className="flex flex-col gap-[4px]">
426
+ {/* Static deal window (time slot dropdown disabled below)
427
+ <span className="text-[14px] font-bold leading-none text-white tracking-wide whitespace-nowrap">
428
+ Entre {dealWindowFrom} y {dealWindowTo}
429
+ </span> */}
430
+ {/* Time slot dropdown — disabled for now, kept for reference
307
431
  <div
308
432
  className="kupos-time-dd relative"
309
433
  tabIndex={0}
@@ -390,31 +514,29 @@ const FeatureServiceUi = ({
390
514
  </>
391
515
  )}
392
516
  </div>
517
+ */}
393
518
 
394
- <div className="text-[13px] text-[#9b9b9b]">
395
- {travelDate
396
- ? (() => {
397
- const dayName = DateService.getDayNameFromDate(
398
- travelDate,
399
- "yyyy-mm-dd",
400
- "full",
401
- );
402
- const day = parseInt(
403
- DateService.getDateFromDate(travelDate, "yyyy-mm-dd"),
404
- 10,
405
- );
406
- const monthName = DateService.getMonthNameFromDate(
407
- travelDate,
408
- "yyyy-mm-dd",
409
- "full",
410
- );
411
- return `${dayName.charAt(0).toUpperCase() + dayName.slice(1)} ${day} de ${monthName}`;
412
- })()
413
- : "Viernes 23 de mayo"}
519
+ <div className="flex items-center gap-[12px] text-[13px] text-white">
520
+ <CalendarIcon />
521
+ <span>{formatTravelDate(travelDate)}</span>
414
522
  </div>
415
523
  </div>
416
524
 
417
- <div className="flex flex-col items-start gap-[12px] text-[13px] mt-[6px]">
525
+ <div className="mt-auto flex items-center gap-[12px]">
526
+ <div className="shrink-0">
527
+ {renderIcon("sheildIcon", "24px")}
528
+ </div>
529
+ <div className="flex flex-col gap-[2px]">
530
+ <span className="bold-text text-[13px] text-white">
531
+ Precio Remate
532
+ </span>
533
+ <span className="text-[11px] text-[#dcdcdc]">
534
+ Sin cambios, sin cancelación
535
+ </span>
536
+ </div>
537
+ </div>
538
+
539
+ {/* <div className="flex flex-col items-start gap-[12px] text-[13px] mt-[6px]">
418
540
  <div className="flex items-start gap-[10px]">
419
541
  <div className="mt-[2px] opacity-80">
420
542
  {renderIcon("confirmarIcon", "20px")}
@@ -431,70 +553,108 @@ const FeatureServiceUi = ({
431
553
  Si nadie confirma, <br /> te devolvemos tu dinero
432
554
  </span>
433
555
  </div>
434
- </div>
556
+ </div> */}
435
557
  </div>
436
558
 
437
559
  {/* MIDDLE: competing operators + viewers */}
438
- <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]">
560
+ <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]">
439
561
  <div className="text-center">
440
562
  <div className="bold-text text-[13px]">
441
- {servicesInActiveSlot.length > 0
442
- ? servicesInActiveSlot.length
563
+ {sortedServices.length > 0
564
+ ? sortedServices.length
443
565
  : operatorsCompetingCount}{" "}
444
- operadores compitiendo por tu compra
566
+ ofertas compitiendo por tu compra
445
567
  </div>
446
568
  </div>
447
569
 
448
570
  <div
449
- className="grid items-stretch gap-[14px]"
571
+ id={`feature-cards-${serviceItem.id}`}
572
+ className="feature-cards-scroll flex w-full items-stretch gap-[14px] overflow-x-auto"
450
573
  style={{
451
- gridTemplateColumns: `repeat(${Math.min(
452
- servicesInActiveSlot.length || operators.length,
453
- 3,
454
- )}, minmax(80px, 1fr))`,
574
+ justifyContent: hasCardScroll ? "flex-start" : "center",
575
+ // room for the crown badge that sits above the selected card
576
+ paddingTop: "12px",
577
+ paddingBottom: 0,
578
+ scrollbarWidth: "none",
455
579
  }}
456
580
  >
457
- {(servicesInActiveSlot.length > 0
458
- ? servicesInActiveSlot.slice(0, 3).map((s: any) => ({
459
- logo: s.operator_logo_url,
460
- name: s.operator_name,
461
- time: s.departure_time,
462
- seatsAvailable: `${s.available_seats} disponibles`,
463
- }))
464
- : operators
465
- ).map((op, idx) => (
466
- <div
467
- key={idx}
468
- className="flex min-w-0 flex-col items-center justify-center gap-[8px] rounded-[8px]"
469
- style={{
470
- // border: "1px solid #363c48",
471
- // backgroundColor: "#fff",
472
- border: "1px solid #363c48",
473
- backgroundColor: "#1a202e",
474
- padding: "25px 10px",
475
- }}
476
- >
477
- <img
478
- src={op.logo}
479
- alt={op.name}
480
- onError={(e) => {
481
- e.currentTarget.src =
482
- serviceItem?.operator_details?.[0] ||
483
- "/images/service-list/bus-icon.svg";
581
+ {serviceCards.map((op) => {
582
+ const isSelected = op.key === selectedCard?.key;
583
+ return (
584
+ <div
585
+ key={op.key}
586
+ data-card-key={op.key}
587
+ role="button"
588
+ tabIndex={0}
589
+ aria-pressed={isSelected}
590
+ onClick={() => handleCardSelect(op.key)}
591
+ onKeyDown={(e) => {
592
+ if (e.key === "Enter" || e.key === " ") {
593
+ e.preventDefault();
594
+ handleCardSelect(op.key);
595
+ }
484
596
  }}
485
- className={`h-[24px] max-w-full object-contain`}
486
- />
487
- <span className="text-[11px] truncate w-full text-center text-[white] overflow-hidden">
488
- {op.name}
489
- </span>
490
- <div className="bg-[#FF8F45] text-white text-[12px] font-bold px-[10px] py-[4px] rounded-[4px] bold-text whitespace-nowrap">
491
- <span>{op?.time}</span>
597
+ className="relative flex min-w-0 cursor-pointer flex-col items-center justify-center gap-[8px] rounded-[8px] outline-none"
598
+ style={{
599
+ // 3 cards visible; the rest scroll horizontally
600
+ flex: "0 0 calc((100% - 28px) / 3)",
601
+ minWidth: "80px",
602
+ border: isSelected ? CARD_SELECTED_BORDER : CARD_BORDER,
603
+ boxShadow: isSelected ? CARD_SELECTED_SHADOW : "none",
604
+ backgroundColor: "#1a202e",
605
+ padding: "20px 10px 16px",
606
+ borderRadius: "12px",
607
+ transition:
608
+ "border-color 150ms ease, box-shadow 150ms ease",
609
+ }}
610
+ >
611
+ {/* Crown badge on the selected card */}
612
+ <span
613
+ data-card-check="true"
614
+ className="absolute items-center justify-center"
615
+ style={{
616
+ display: isSelected ? "flex" : "none",
617
+ top: "-9px",
618
+ right: "7px",
619
+ width: "32px",
620
+ height: "20px",
621
+ borderRadius: "6px",
622
+ backgroundColor: "#FF5C60",
623
+ }}
624
+ >
625
+ <CrownIcon />
626
+ </span>
627
+ <img
628
+ src={op.logo}
629
+ alt={op.name}
630
+ onError={(e) => {
631
+ e.currentTarget.src =
632
+ serviceItem?.operator_details?.[0] ||
633
+ "/images/service-list/bus-icon.svg";
634
+ }}
635
+ className={`h-[24px] max-w-full object-contain`}
636
+ />
637
+ <span className="text-[11px] truncate w-full text-center text-[white] overflow-hidden">
638
+ {op.name}
639
+ </span>
640
+ <div className="bg-[#FF8F45] text-white text-[12px] font-bold px-[14px] py-[4px] rounded-[6px] bold-text whitespace-nowrap">
641
+ <span>{commonService.currencyRounded(op.price)}</span>
642
+ </div>
643
+ <span className="bold-text text-[12px] text-white whitespace-nowrap">
644
+ {formatDepartureTime(op?.time)}
645
+ </span>
646
+ <span className="bold-text text-[10px] text-white whitespace-nowrap">
647
+ {op?.seatsAvailable}
648
+ </span>
649
+ {/* <div className="bg-[#FF8F45] text-white text-[12px] font-bold px-[10px] py-[4px] rounded-[4px] bold-text whitespace-nowrap">
650
+ <span>{op?.time}</span>
651
+ </div>
652
+ <span className="text-[10px] mt-[6px] text-[white]">
653
+ {op?.seatsAvailable}
654
+ </span> */}
492
655
  </div>
493
- <span className="text-[10px] mt-[6px] text-[white]">
494
- {op?.seatsAvailable}
495
- </span>
496
- </div>
497
- ))}
656
+ );
657
+ })}
498
658
  </div>
499
659
 
500
660
  <div
@@ -554,7 +714,10 @@ const FeatureServiceUi = ({
554
714
  whiteSpace: "nowrap",
555
715
  }}
556
716
  >
557
- {displaySavingsPercent}% OFF
717
+ <span data-feature-savings={serviceItem.id}>
718
+ {displaySavingsPercent}
719
+ </span>
720
+ % OFF
558
721
  </span>
559
722
  {/* <span className="text-[#666] text-[14px] line-through">
560
723
  $10.000
@@ -563,9 +726,11 @@ const FeatureServiceUi = ({
563
726
  className="text-[13.33px] font-normal leading-[20px] text-[#9f9f9f] relative"
564
727
  style={{ position: "relative" }}
565
728
  >
566
- {commonService.currencyRounded(
567
- displayOriginalPrice * ticketQuantity,
568
- )}
729
+ <span id={`feature-original-price-${serviceItem.id}`}>
730
+ {commonService.currencyRounded(
731
+ displayOriginalPrice * ticketQuantity,
732
+ )}
733
+ </span>
569
734
  <span
570
735
  style={{
571
736
  position: "absolute",
@@ -581,7 +746,10 @@ const FeatureServiceUi = ({
581
746
  }}
582
747
  />
583
748
  </span>
584
- <span className="text-white bold-text text-[28px] leading-none">
749
+ <span
750
+ id={`feature-final-price-${serviceItem.id}`}
751
+ className="text-white bold-text text-[28px] leading-none"
752
+ >
585
753
  {commonService.currencyRounded(
586
754
  displayFinalPrice * ticketQuantity,
587
755
  )}
@@ -643,6 +811,28 @@ const FeatureServiceUi = ({
643
811
  if (!btn) return;
644
812
  btn.onclick = () => {
645
813
  if (btn.disabled) return;
814
+
815
+ // Require an operator card to be selected before booking
816
+ const currentCard = serviceCards.find(
817
+ (c) => c.key === selectedCardMap[serviceItem.id],
818
+ );
819
+ if (!currentCard) {
820
+ selectErrorMap[serviceItem.id] = true;
821
+ const errorNode = document.getElementById(
822
+ `feature-select-error-${serviceItem.id}`,
823
+ );
824
+ if (errorNode) errorNode.style.display = "block";
825
+ return;
826
+ }
827
+ const chosenService = currentCard.service;
828
+ // Selected service first so consumers reading [0] get the chosen one
829
+ const orderedServices = chosenService
830
+ ? [
831
+ chosenService,
832
+ ...sortedServices.filter((s) => s !== chosenService),
833
+ ]
834
+ : sortedServices;
835
+
646
836
  btn.disabled = true;
647
837
  btn.style.cursor = "not-allowed";
648
838
  btn.style.opacity = "0.85";
@@ -657,7 +847,8 @@ const FeatureServiceUi = ({
657
847
  onBookButtonPress?.(
658
848
  ticketQuantity,
659
849
  serviceItem,
660
- servicesInActiveSlot,
850
+ orderedServices,
851
+ chosenService,
661
852
  );
662
853
  };
663
854
  }}
@@ -697,6 +888,17 @@ const FeatureServiceUi = ({
697
888
  />
698
889
  </span>
699
890
  </button>
891
+ <span
892
+ id={`feature-select-error-${serviceItem.id}`}
893
+ role="alert"
894
+ className="text-[11px] text-[#FF5C60] text-center leading-[14px]"
895
+ style={{
896
+ display: isSelectErrorVisible ? "block" : "none",
897
+ marginTop: "-4px",
898
+ }}
899
+ >
900
+ Selecciona un horario para continuar
901
+ </span>
700
902
  </div>
701
903
 
702
904
  <div