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.
@@ -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,111 @@ 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
+ // Crown stays on the lowest fare card โ€” selection only moves the border
216
+ });
217
+
218
+ const card = serviceCards.find((c) => c.key === key);
219
+ const price = getPriceInfo(card?.service || sortedServices[0]);
220
+ setNodeText(
221
+ document.getElementById(`feature-final-price-${serviceItem.id}`),
222
+ commonService.currencyRounded(price.final * ticketQuantity),
223
+ );
224
+ setNodeText(
225
+ document.getElementById(`feature-original-price-${serviceItem.id}`),
226
+ commonService.currencyRounded(price.original * ticketQuantity),
227
+ );
228
+ document
229
+ .querySelectorAll(`[data-feature-savings="${serviceItem.id}"]`)
230
+ .forEach((node) => setNodeText(node, String(price.savings)));
231
+
232
+ const errorNode = document.getElementById(
233
+ `feature-select-error-${serviceItem.id}`,
234
+ );
235
+ if (errorNode) errorNode.style.display = "none";
236
+ };
143
237
 
144
238
  // The label shown on the dropdown button
145
239
 
@@ -147,14 +241,17 @@ const FeatureServiceUi = ({
147
241
  activeSlot?.label || `${dealWindowFrom} y ${dealWindowTo}`;
148
242
  // activeSlot?.label || `Entre ${dealWindowFrom} y ${dealWindowTo}`;
149
243
 
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;
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;
158
255
 
159
256
  if (expandedStateMap[serviceItem.id] === undefined) {
160
257
  expandedStateMap[serviceItem.id] = true;
@@ -241,7 +338,13 @@ const FeatureServiceUi = ({
241
338
  fontSize: "12px",
242
339
  }}
243
340
  >
244
- <span>AHORRAS {displaySavingsPercent}%</span>
341
+ <span>
342
+ AHORRAS{" "}
343
+ <span data-feature-savings={serviceItem.id}>
344
+ {displaySavingsPercent}
345
+ </span>
346
+ %
347
+ </span>
245
348
  </div>
246
349
  </div>
247
350
  <div
@@ -279,8 +382,23 @@ const FeatureServiceUi = ({
279
382
  >
280
383
  <div className="grid grid-cols-[27%_46%_27%] items-stretch">
281
384
  {/* 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]">
385
+ <div className="flex flex-col gap-[14px] mb-[16px] pr-[22px]">
386
+ {/* mt-auto here + mt-auto on "Precio Remate" centers origin/destination/date vertically */}
387
+ <div className="mt-auto flex flex-col gap-[12px]">
388
+ <div className="flex items-center gap-[12px]">
389
+ <OriginIcon />
390
+ <span className="text-[16px] bold-text text-white tracking-wide">
391
+ {cityOrigin?.label.split(",")[0]}
392
+ </span>
393
+ </div>
394
+ <div className="flex items-center gap-[12px]">
395
+ <DestinationIcon />
396
+ <span className="text-[16px] bold-text text-white tracking-wide">
397
+ {cityDestination?.label.split(",")[0]}
398
+ </span>
399
+ </div>
400
+ </div>
401
+ {/* <div className="flex items-center gap-[6px]">
284
402
  <span className="text-[16px] bold-text text-white tracking-wide">
285
403
  {cityOrigin?.label.split(",")[0]}
286
404
  </span>
@@ -288,9 +406,9 @@ const FeatureServiceUi = ({
288
406
  <span className="text-[16px] bold-text text-white tracking-wide">
289
407
  {cityDestination?.label.split(",")[0]}
290
408
  </span>
291
- </div>
409
+ </div> */}
292
410
 
293
- <div
411
+ {/* <div
294
412
  className="flex items-center w-fit rounded-[6px] px-[8px] py-[4px] gap-[6px] "
295
413
  style={{
296
414
  border: "1px solid rgb(54, 60, 72)",
@@ -301,9 +419,14 @@ const FeatureServiceUi = ({
301
419
  {renderIcon("flexibleIcon", "14px")}
302
420
  </div>
303
421
  <span className="text-[12px] text-white">Salida flexible</span>
304
- </div>
422
+ </div> */}
305
423
 
306
- <div className="flex flex-col gap-[4px] mt-[4px]">
424
+ <div className="flex flex-col gap-[4px]">
425
+ {/* Static deal window (time slot dropdown disabled below)
426
+ <span className="text-[14px] font-bold leading-none text-white tracking-wide whitespace-nowrap">
427
+ Entre {dealWindowFrom} y {dealWindowTo}
428
+ </span> */}
429
+ {/* Time slot dropdown โ€” disabled for now, kept for reference
307
430
  <div
308
431
  className="kupos-time-dd relative"
309
432
  tabIndex={0}
@@ -390,31 +513,29 @@ const FeatureServiceUi = ({
390
513
  </>
391
514
  )}
392
515
  </div>
516
+ */}
393
517
 
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"}
518
+ <div className="flex items-center gap-[12px] text-[13px] text-white">
519
+ <CalendarIcon />
520
+ <span>{formatTravelDate(travelDate)}</span>
414
521
  </div>
415
522
  </div>
416
523
 
417
- <div className="flex flex-col items-start gap-[12px] text-[13px] mt-[6px]">
524
+ <div className="mt-auto flex items-center gap-[12px]">
525
+ <div className="shrink-0">
526
+ {renderIcon("sheildIcon", "24px")}
527
+ </div>
528
+ <div className="flex flex-col gap-[2px]">
529
+ <span className="bold-text text-[13px] text-white">
530
+ Precio Remate
531
+ </span>
532
+ <span className="text-[11px] text-[#dcdcdc]">
533
+ Sin cambios, sin cancelaciรณn
534
+ </span>
535
+ </div>
536
+ </div>
537
+
538
+ {/* <div className="flex flex-col items-start gap-[12px] text-[13px] mt-[6px]">
418
539
  <div className="flex items-start gap-[10px]">
419
540
  <div className="mt-[2px] opacity-80">
420
541
  {renderIcon("confirmarIcon", "20px")}
@@ -431,70 +552,109 @@ const FeatureServiceUi = ({
431
552
  Si nadie confirma, <br /> te devolvemos tu dinero
432
553
  </span>
433
554
  </div>
434
- </div>
555
+ </div> */}
435
556
  </div>
436
557
 
437
558
  {/* 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]">
559
+ <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
560
  <div className="text-center">
440
561
  <div className="bold-text text-[13px]">
441
- {servicesInActiveSlot.length > 0
442
- ? servicesInActiveSlot.length
562
+ {sortedServices.length > 0
563
+ ? sortedServices.length
443
564
  : operatorsCompetingCount}{" "}
444
- operadores compitiendo por tu compra
565
+ ofertas compitiendo por tu compra
445
566
  </div>
446
567
  </div>
447
568
 
448
569
  <div
449
- className="grid items-stretch gap-[14px]"
570
+ id={`feature-cards-${serviceItem.id}`}
571
+ className="feature-cards-scroll flex w-full items-stretch gap-[14px] overflow-x-auto"
450
572
  style={{
451
- gridTemplateColumns: `repeat(${Math.min(
452
- servicesInActiveSlot.length || operators.length,
453
- 3,
454
- )}, minmax(80px, 1fr))`,
573
+ justifyContent: hasCardScroll ? "flex-start" : "center",
574
+ // room for the crown badge that sits above the selected card
575
+ paddingTop: "12px",
576
+ paddingBottom: 0,
577
+ scrollbarWidth: "none",
455
578
  }}
456
579
  >
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";
580
+ {serviceCards.map((op, idx) => {
581
+ const isSelected = op.key === selectedCard?.key;
582
+ // Cards are sorted cheapest first, so index 0 is the lowest fare
583
+ const isLowestFare = idx === 0;
584
+ return (
585
+ <div
586
+ key={op.key}
587
+ data-card-key={op.key}
588
+ role="button"
589
+ tabIndex={0}
590
+ aria-pressed={isSelected}
591
+ onClick={() => handleCardSelect(op.key)}
592
+ onKeyDown={(e) => {
593
+ if (e.key === "Enter" || e.key === " ") {
594
+ e.preventDefault();
595
+ handleCardSelect(op.key);
596
+ }
484
597
  }}
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>
598
+ className="relative flex min-w-0 cursor-pointer flex-col items-center justify-center gap-[8px] rounded-[8px] outline-none"
599
+ style={{
600
+ // 3 cards visible; the rest scroll horizontally
601
+ flex: "0 0 calc((100% - 28px) / 3)",
602
+ minWidth: "80px",
603
+ border: isSelected ? CARD_SELECTED_BORDER : CARD_BORDER,
604
+ boxShadow: isSelected ? CARD_SELECTED_SHADOW : "none",
605
+ backgroundColor: "#1a202e",
606
+ padding: "20px 10px 16px",
607
+ borderRadius: "12px",
608
+ transition:
609
+ "border-color 150ms ease, box-shadow 150ms ease",
610
+ }}
611
+ >
612
+ {/* Crown badge โ€” always on the lowest fare card */}
613
+ <span
614
+ className="absolute items-center justify-center"
615
+ style={{
616
+ display: isLowestFare ? "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