@qite/tide-booking-component 1.4.101 → 1.4.103

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.
Files changed (45) hide show
  1. package/build/build-cjs/index.js +998 -451
  2. package/build/build-cjs/src/index.d.ts +3 -1
  3. package/build/build-cjs/src/search-results/components/flight/flight-selection/independent-flight-option.d.ts +3 -0
  4. package/build/build-cjs/src/search-results/store/search-results-slice.d.ts +8 -1
  5. package/build/build-cjs/src/search-results/types.d.ts +3 -1
  6. package/build/build-cjs/src/search-results/utils/flight-utils.d.ts +1 -0
  7. package/build/build-cjs/src/shared/utils/localization-util.d.ts +2 -0
  8. package/build/build-esm/index.js +998 -445
  9. package/build/build-esm/src/index.d.ts +3 -1
  10. package/build/build-esm/src/search-results/components/flight/flight-selection/independent-flight-option.d.ts +3 -0
  11. package/build/build-esm/src/search-results/store/search-results-slice.d.ts +8 -1
  12. package/build/build-esm/src/search-results/types.d.ts +3 -1
  13. package/build/build-esm/src/search-results/utils/flight-utils.d.ts +1 -0
  14. package/build/build-esm/src/shared/utils/localization-util.d.ts +2 -0
  15. package/package.json +2 -2
  16. package/src/index.ts +3 -1
  17. package/src/search-results/components/flight/flight-selection/independent-flight-option.tsx +30 -9
  18. package/src/search-results/components/flight/flight-selection/independent-flight-selection.tsx +2 -6
  19. package/src/search-results/components/hotel/hotel-accommodation-results.tsx +11 -12
  20. package/src/search-results/components/icon.tsx +1 -1
  21. package/src/search-results/components/itinerary/index.tsx +0 -2
  22. package/src/search-results/components/search-results-container/search-results-container.tsx +695 -83
  23. package/src/search-results/store/search-results-slice.ts +20 -1
  24. package/src/search-results/types.ts +3 -1
  25. package/src/search-results/utils/flight-utils.ts +5 -0
  26. package/src/shared/components/flyin/accommodation-flyin.tsx +4 -2
  27. package/src/shared/translations/ar-SA.json +2 -0
  28. package/src/shared/translations/da-DK.json +2 -0
  29. package/src/shared/translations/de-DE.json +2 -0
  30. package/src/shared/translations/en-GB.json +2 -0
  31. package/src/shared/translations/es-ES.json +2 -0
  32. package/src/shared/translations/fr-BE.json +2 -0
  33. package/src/shared/translations/fr-FR.json +2 -0
  34. package/src/shared/translations/is-IS.json +2 -0
  35. package/src/shared/translations/it-IT.json +2 -0
  36. package/src/shared/translations/ja-JP.json +2 -0
  37. package/src/shared/translations/nl-BE.json +2 -0
  38. package/src/shared/translations/nl-NL.json +2 -0
  39. package/src/shared/translations/no-NO.json +2 -0
  40. package/src/shared/translations/pl-PL.json +2 -0
  41. package/src/shared/translations/pt-PT.json +2 -0
  42. package/src/shared/translations/sv-SE.json +2 -0
  43. package/src/shared/utils/localization-util.ts +5 -2
  44. package/styles/components/_flight-option.scss +14 -1
  45. package/styles/components/_search.scss +5 -0
@@ -18,7 +18,11 @@ import {
18
18
  setTransactionId,
19
19
  setAccommodationFlyInStep,
20
20
  setPriceDetails,
21
- setItinerary
21
+ setItinerary,
22
+ setFlightsLoading,
23
+ setPackagingFlightResults,
24
+ setSelectedPackagingFlight,
25
+ setSelectedPackagingAccoResult
22
26
  } from '../../store/search-results-slice';
23
27
  import { AccommodationFlyInStep, Filter, SearchSeed, SortByType } from '../../types';
24
28
  import useMediaQuery from '../../../shared/utils/use-media-query-util';
@@ -41,10 +45,16 @@ import {
41
45
  startTransaction,
42
46
  PackagingEntryLine,
43
47
  getPriceDetails,
44
- getItinerary
48
+ getItinerary,
49
+ FlightSearchRequest,
50
+ searchPackagingFlights,
51
+ PackagingFlightResponse,
52
+ PackagingAccommodationResponse,
53
+ FlightSearchResponseFlightSegment,
54
+ PackagingEntryLineFlightLine
45
55
  } from '@qite/tide-client';
46
56
  import { getDateFromParams, getNumberFromParams, getRoomsFromParams, getStringFromParams } from '../../../shared/utils/query-string-util';
47
- import { first, last, range } from 'lodash';
57
+ import { concat, first, isEmpty, last, range } from 'lodash';
48
58
  import { Room } from '../../../booking-wizard/types';
49
59
  import Icon from '../icon';
50
60
  import Itinerary from '../itinerary';
@@ -53,7 +63,7 @@ import FlyIn from '../../../shared/components/flyin/flyin';
53
63
  import HotelAccommodationResults from '../hotel/hotel-accommodation-results';
54
64
  import RoundTripResults from '../round-trip/round-trip-results';
55
65
  import FlightResults from '../flight/flight-results';
56
- import { findSortByType, getSortingName, getTranslations } from '../../../shared/utils/localization-util';
66
+ import { dateToDateStruct, findSortByType, getSortingName, getTranslations } from '../../../shared/utils/localization-util';
57
67
  import { FlightSearchProvider } from '../flight/flight-search-context';
58
68
  import FlightResultsContainer from './flight-search-results';
59
69
  import Filters from '../filters/filters';
@@ -77,6 +87,20 @@ import {
77
87
  } from '../../utils/query-utils';
78
88
  import { getRequestRoomsFromPackagingSegments, getRoomIndexFromLine, getSelectedOptionsPerRoom } from '../../utils/packaging-utils';
79
89
  import FullItinerary from '../itinerary/full-itinerary';
90
+ import { getFlightKey } from '../../utils/flight-utils';
91
+ import IndependentFlightOption from '../flight/flight-selection/independent-flight-option';
92
+ import { Spinner } from '../../..';
93
+ import { PackagingRequestBase } from '@qite/tide-client/build/types/booking-v2/request/packaging/packaging-request-base';
94
+
95
+ type BuildPackagingEntryPartialArgs = {
96
+ sourceEntry: PackagingEntry | null | undefined;
97
+ selectedHotelCode: string | null | undefined;
98
+ accommodationResults: PackagingAccommodationResponse[];
99
+ selectedFlight: PackagingFlightResponse | null;
100
+ seed: SearchSeed;
101
+ transactionId: string;
102
+ language: string;
103
+ };
80
104
 
81
105
  const SearchResultsContainer: React.FC = () => {
82
106
  const currentSearch = typeof window !== 'undefined' ? window.location.search : '';
@@ -93,6 +117,7 @@ const SearchResultsContainer: React.FC = () => {
93
117
  filteredPackagingAccoResults,
94
118
  bookingPackageDetails,
95
119
  isLoading,
120
+ flightsLoading,
96
121
  filters,
97
122
  selectedSortType,
98
123
  selectedSearchResult,
@@ -102,7 +127,8 @@ const SearchResultsContainer: React.FC = () => {
102
127
  editablePackagingEntry,
103
128
  transactionId,
104
129
  accommodationFlyInStep,
105
- itinerary
130
+ itinerary,
131
+ packagingFlightResults
106
132
  } = useSelector((state: SearchResultsRootState) => state.searchResults);
107
133
 
108
134
  const isMobile = useMediaQuery('(max-width: 1200px)');
@@ -119,6 +145,10 @@ const SearchResultsContainer: React.FC = () => {
119
145
 
120
146
  const [selectedAccommodationSeed, setSelectedAccommodationSeed] = useState<SearchSeed | null>(null);
121
147
 
148
+ const skipInitialPackagingAccoDetailsRef = useRef(false);
149
+
150
+ const [showAllOutwardFlights, setShowAllOutwardFlights] = useState(false);
151
+
122
152
  const panelRef = useRef<HTMLDivElement | null>(null);
123
153
 
124
154
  const sortByTypes: SortByType[] = [
@@ -276,7 +306,7 @@ const SearchResultsContainer: React.FC = () => {
276
306
 
277
307
  return {
278
308
  transactionId: currentTransactionId,
279
- officeId: 1,
309
+ officeId: context?.tideConnection?.officeId ?? 1,
280
310
  agentId: context?.agentId ?? null,
281
311
  catalogueId: context!.searchConfiguration.defaultCatalogueId ?? 0,
282
312
  searchConfigurationId: context!.searchConfiguration.id,
@@ -299,6 +329,43 @@ const SearchResultsContainer: React.FC = () => {
299
329
  };
300
330
  };
301
331
 
332
+ const buildPackagingFlightRequestFromSeed = (seed: SearchSeed, currentTransactionId: string): FlightSearchRequest => {
333
+ if (typeof window !== 'undefined') {
334
+ window.scrollTo(0, 0);
335
+ }
336
+ var adults = seed.rooms.flatMap((x) => x.pax).filter((x) => x.age! >= 18).length;
337
+ var kids = seed.rooms.flatMap((x) => x.pax).filter((x) => x.age! >= 2 && x.age! < 18).length;
338
+ var babies = seed.rooms.flatMap((x) => x.pax).filter((x) => x.age! < 2).length;
339
+
340
+ return {
341
+ transactionId: currentTransactionId,
342
+ officeId: context?.tideConnection?.officeId ?? 1,
343
+ catalogueId: first(context?.tideConnection?.catalogueIds) ?? 0,
344
+ departureAirportCode: seed.departureAirport,
345
+ arrivalAirportCode: seed.destinationAirport,
346
+ returnAirportCode: seed.returnAirport,
347
+ luggageIncluded: null,
348
+ maxStops: null,
349
+ travelClass: seed.travelClass,
350
+ pax: concat(
351
+ Array.from({ length: adults ?? 0 }, (_, index) => ({
352
+ id: index,
353
+ age: 31
354
+ })),
355
+ Array.from({ length: kids ?? 0 }, (_, index) => ({
356
+ id: index + (adults ?? 0),
357
+ age: 8
358
+ })),
359
+ Array.from({ length: babies ?? 0 }, (_, index) => ({
360
+ id: index + (adults ?? 0) + (kids ?? 0),
361
+ age: 1
362
+ }))
363
+ ),
364
+ outward: seed.fromDate ? { date: dateToDateStruct(new Date(seed.fromDate)) } : null,
365
+ return: seed.toDate ? { date: dateToDateStruct(new Date(seed.toDate)) } : null
366
+ } as FlightSearchRequest;
367
+ };
368
+
302
369
  const buildSearchSeedFromQueryParams = (params: URLSearchParams): SearchSeed | null => {
303
370
  const from = getDateFromParams(params, 'fromDate');
304
371
  const to = getDateFromParams(params, 'toDate');
@@ -311,6 +378,8 @@ const SearchResultsContainer: React.FC = () => {
311
378
  const tagId = getNumberFromParams(params, 'tagId');
312
379
  const destinationAirport = getStringFromParams(params, 'destinationAirport');
313
380
  const departureAirport = getStringFromParams(params, 'departureAirport');
381
+ const travelClass = getStringFromParams(params, 'travelClass');
382
+ const nationality = getStringFromParams(params, 'nationality');
314
383
 
315
384
  if (!from || !to) {
316
385
  return null;
@@ -328,10 +397,55 @@ const SearchResultsContainer: React.FC = () => {
328
397
  tagId,
329
398
  destinationAirport,
330
399
  departureAirport,
400
+ travelClass,
401
+ nationality,
331
402
  rooms: getRequestRooms(rooms)
332
403
  };
333
404
  };
334
405
 
406
+ const buildSearchSeedFromAccommodationSegments = (entry: PackagingEntry, segments: PackagingEntryLine[]): SearchSeed | null => {
407
+ if (!segments?.length) return null;
408
+
409
+ const sortedSegments = [...segments].sort((a, b) => new Date(a.from).getTime() - new Date(b.from).getTime());
410
+
411
+ const firstSegment = first(sortedSegments);
412
+ const lastSegment = last(sortedSegments);
413
+
414
+ if (!firstSegment || !lastSegment) return null;
415
+
416
+ return {
417
+ fromDate: toDateOnlyString(firstSegment.from),
418
+ toDate: toDateOnlyString(lastSegment.to),
419
+ country: firstSegment.country?.id ?? null,
420
+ region: firstSegment.region?.id ?? null,
421
+ oord: firstSegment.oord?.id ?? null,
422
+ location: firstSegment.location?.id ?? null,
423
+ hotel: parseHotelId(firstSegment),
424
+ hotelCode: firstSegment.productCode ?? null,
425
+ tagId: null,
426
+ destinationAirport: getDestinationAirportFromEntry(entry.lines ?? []),
427
+ departureAirport: getDepartureAirportFromEntry(entry.lines ?? []),
428
+ rooms: getRequestRoomsFromPackagingSegments(entry, sortedSegments)
429
+ };
430
+ };
431
+
432
+ const handleEditAccommodation = async (segments: PackagingEntryLine[]) => {
433
+ const sourceEntry = editablePackagingEntry ?? context?.packagingEntry;
434
+ if (!sourceEntry) return;
435
+
436
+ const seed = buildSearchSeedFromAccommodationSegments(sourceEntry, segments);
437
+ if (!seed) return;
438
+
439
+ setDetailsIsLoading(true);
440
+
441
+ setSelectedAccommodationSeed(seed);
442
+ dispatch(setAccommodationFlyInStep('results'));
443
+ handleFlyInToggle(true);
444
+ const currentTransactionId = await getOrCreateTransactionId();
445
+ await runAccommodationFlow(seed, currentTransactionId ?? '');
446
+ setDetailsIsLoading(false);
447
+ };
448
+
335
449
  const handleConfirmHotelSwap = () => {
336
450
  const updatedEntry = swapHotelInPackagingEntry();
337
451
  if (!updatedEntry) return;
@@ -431,6 +545,18 @@ const SearchResultsContainer: React.FC = () => {
431
545
  }
432
546
  }, [filtersOpen]);
433
547
 
548
+ const getOrCreateTransactionId = async (): Promise<string | null> => {
549
+ if (context?.packagingEntry?.transactionId) {
550
+ return context.packagingEntry.transactionId;
551
+ }
552
+
553
+ if (transactionId) {
554
+ return transactionId;
555
+ }
556
+
557
+ return await runStartTransaction();
558
+ };
559
+
434
560
  const runSearch = async () => {
435
561
  try {
436
562
  if (!context) return;
@@ -476,19 +602,21 @@ const SearchResultsContainer: React.FC = () => {
476
602
  try {
477
603
  if (!context) return null;
478
604
 
605
+ dispatch(setIsLoading(true));
606
+
479
607
  const config: TideClientConfig = {
480
608
  host: context.tideConnection.host,
481
609
  apiKey: context.tideConnection.apiKey
482
610
  };
483
611
 
484
612
  const transaction = await startTransaction(config);
485
- console.log('Transaction started', transaction);
486
613
 
487
614
  dispatch(setTransactionId(transaction.transactionId));
488
-
615
+ dispatch(setIsLoading(false));
489
616
  return transaction.transactionId;
490
617
  } catch (err) {
491
618
  console.error('Transaction failed', err);
619
+ dispatch(setIsLoading(false));
492
620
  return null;
493
621
  }
494
622
  };
@@ -520,28 +648,55 @@ const SearchResultsContainer: React.FC = () => {
520
648
  const initialFilteredResults = applyFiltersToPackageAccoResults(packageAccoSearchResults, filters, null);
521
649
  dispatch(setFilteredPackagingAccoResults(initialFilteredResults));
522
650
 
651
+ if (initialFilteredResults.length > 0) {
652
+ skipInitialPackagingAccoDetailsRef.current = true;
653
+ dispatch(setSelectedPackagingAccoResult(first(initialFilteredResults)?.code ?? null));
654
+ }
655
+
523
656
  dispatch(setIsLoading(false));
524
657
  } catch (err) {
525
- console.error('Search failed', err);
658
+ console.error('HotelSearch failed', err);
526
659
  dispatch(setIsLoading(false));
527
660
  }
528
661
  };
529
662
 
530
- const runAccommodationFlow = async (seed: SearchSeed) => {
663
+ const runAccommodationFlow = async (seed: SearchSeed, currentTransactionId: string) => {
531
664
  if (!context || context.showMockup) return;
665
+ await runHotelSearch(currentTransactionId, seed);
666
+ };
532
667
 
533
- let currentTransactionId = context?.packagingEntry?.transactionId || transactionId;
534
- if (!currentTransactionId) {
535
- dispatch(setIsLoading(true));
536
- currentTransactionId = await runStartTransaction();
537
- }
668
+ const runFlightSearch = async (currentTransactionId: string, seed: SearchSeed) => {
669
+ try {
670
+ if (!context) return;
671
+ dispatch(setFlightsLoading(true));
538
672
 
539
- if (!currentTransactionId) {
540
- dispatch(setIsLoading(false));
541
- return;
673
+ const config: TideClientConfig = {
674
+ host: context.tideConnection.host,
675
+ apiKey: context.tideConnection.apiKey
676
+ };
677
+
678
+ let searchRequest: FlightSearchRequest = buildPackagingFlightRequestFromSeed(seed, currentTransactionId);
679
+ searchRequest.agentId = context.agentId;
680
+
681
+ const packageFlightSearchResults = await searchPackagingFlights(config, searchRequest);
682
+
683
+ dispatch(setPackagingFlightResults(packageFlightSearchResults));
684
+ const firstResult = first(packageFlightSearchResults);
685
+ if (firstResult) {
686
+ setSelectedOutwardKey(getFlightKey(firstResult.outward.segments));
687
+ setSelectedReturnKey(getFlightKey(firstResult.return.segments));
688
+ }
689
+
690
+ dispatch(setFlightsLoading(false));
691
+ } catch (err) {
692
+ console.error('FlightSearch failed', err);
693
+ dispatch(setFlightsLoading(false));
542
694
  }
695
+ };
543
696
 
544
- await runHotelSearch(currentTransactionId, seed);
697
+ const runFlightFlow = async (seed: SearchSeed, currentTransactionId: string) => {
698
+ if (!context || context.showMockup) return;
699
+ await runFlightSearch(currentTransactionId, seed);
545
700
  };
546
701
 
547
702
  // separate Search
@@ -555,20 +710,41 @@ const SearchResultsContainer: React.FC = () => {
555
710
 
556
711
  if (context?.searchConfiguration.qsmType === PortalQsmType.Accommodation) {
557
712
  const seed = activeSearchSeed;
713
+
558
714
  if (seed) {
559
- runAccommodationFlow(seed);
715
+ (async () => {
716
+ const transactionId = await getOrCreateTransactionId();
717
+ if (!transactionId) return;
718
+
719
+ await runAccommodationFlow(seed, transactionId);
720
+ })();
560
721
  }
561
722
  }
562
723
 
563
724
  if (
564
725
  context?.searchConfiguration.qsmType === PortalQsmType.AccommodationAndFlight &&
565
726
  context.searchConfiguration.enableManualPackaging &&
566
- context.searchConfiguration.allowAccommodations &&
567
727
  !context?.packagingEntry
568
728
  ) {
569
729
  const seed = activeSearchSeed;
730
+
570
731
  if (seed) {
571
- runAccommodationFlow(seed);
732
+ (async () => {
733
+ const sharedTransactionId = await getOrCreateTransactionId();
734
+ if (!sharedTransactionId) return;
735
+
736
+ const tasks: Promise<void>[] = [];
737
+
738
+ if (context.searchConfiguration.allowAccommodations) {
739
+ tasks.push(runAccommodationFlow(seed, sharedTransactionId));
740
+ }
741
+
742
+ if (context.searchConfiguration.allowFlights) {
743
+ tasks.push(runFlightFlow(seed, sharedTransactionId));
744
+ }
745
+
746
+ await Promise.all(tasks);
747
+ })();
572
748
  }
573
749
  }
574
750
  }, [
@@ -583,7 +759,6 @@ const SearchResultsContainer: React.FC = () => {
583
759
 
584
760
  useEffect(() => {
585
761
  if (context?.packagingEntry) {
586
- console.log('original packaging entry from context', context.packagingEntry);
587
762
  dispatch(setEditablePackagingEntry(structuredClone(context.packagingEntry)));
588
763
  dispatch(setTransactionId(context.packagingEntry.transactionId));
589
764
  }
@@ -651,6 +826,12 @@ const SearchResultsContainer: React.FC = () => {
651
826
 
652
827
  const fetchPackagingAccoSearchDetails = async () => {
653
828
  if (!selectedPackagingAccoResultCode || !context) return;
829
+
830
+ if (skipInitialPackagingAccoDetailsRef.current) {
831
+ skipInitialPackagingAccoDetailsRef.current = false;
832
+ return;
833
+ }
834
+
654
835
  setDetailsIsLoading(true);
655
836
  if (
656
837
  context?.searchConfiguration.qsmType === PortalQsmType.Accommodation ||
@@ -759,52 +940,9 @@ const SearchResultsContainer: React.FC = () => {
759
940
  setInitialFiltersSet(false);
760
941
  }, [activeSearchSeed]);
761
942
 
762
- const handleEditAccommodation = async (segments: PackagingEntryLine[]) => {
763
- const sourceEntry = editablePackagingEntry ?? context?.packagingEntry;
764
- if (!sourceEntry) return;
765
-
766
- const seed = buildSearchSeedFromAccommodationSegments(sourceEntry, segments);
767
- if (!seed) return;
768
-
769
- setDetailsIsLoading(true);
770
-
771
- setSelectedAccommodationSeed(seed);
772
- dispatch(setAccommodationFlyInStep('results'));
773
- handleFlyInToggle(true);
774
-
775
- await runAccommodationFlow(seed);
776
- setDetailsIsLoading(false);
777
- };
778
-
779
- const buildSearchSeedFromAccommodationSegments = (entry: PackagingEntry, segments: PackagingEntryLine[]): SearchSeed | null => {
780
- if (!segments?.length) return null;
781
-
782
- const sortedSegments = [...segments].sort((a, b) => new Date(a.from).getTime() - new Date(b.from).getTime());
783
-
784
- const firstSegment = first(sortedSegments);
785
- const lastSegment = last(sortedSegments);
786
-
787
- if (!firstSegment || !lastSegment) return null;
788
-
789
- return {
790
- fromDate: toDateOnlyString(firstSegment.from),
791
- toDate: toDateOnlyString(lastSegment.to),
792
- country: firstSegment.country?.id ?? null,
793
- region: firstSegment.region?.id ?? null,
794
- oord: firstSegment.oord?.id ?? null,
795
- location: firstSegment.location?.id ?? null,
796
- hotel: parseHotelId(firstSegment),
797
- hotelCode: firstSegment.productCode ?? null,
798
- tagId: null,
799
- destinationAirport: getDestinationAirportFromEntry(entry.lines ?? []),
800
- departureAirport: getDepartureAirportFromEntry(entry.lines ?? []),
801
- rooms: getRequestRoomsFromPackagingSegments(entry, sortedSegments)
802
- };
803
- };
804
-
805
943
  useEffect(() => {
806
944
  const fetchPriceDetails = async () => {
807
- if (!context || !editablePackagingEntry) return;
945
+ if (!context || !editablePackagingEntry || isEmpty(editablePackagingEntry.lines)) return;
808
946
  setPricesAreLoading(true);
809
947
  try {
810
948
  const config: TideClientConfig = {
@@ -812,7 +950,15 @@ const SearchResultsContainer: React.FC = () => {
812
950
  apiKey: context.tideConnection.apiKey
813
951
  };
814
952
 
815
- const priceDetails = await getPriceDetails(config, editablePackagingEntry);
953
+ const request = {
954
+ language: context.languageCode ?? 'en-GB',
955
+ officeId: context.tideConnection.officeId,
956
+ catalogueId: context.searchConfiguration.defaultCatalogueId ?? 0,
957
+ agentId: context.agentId,
958
+ payload: editablePackagingEntry
959
+ } as PackagingRequestBase<PackagingEntry>;
960
+
961
+ const priceDetails = await getPriceDetails(config, request);
816
962
  dispatch(setPriceDetails(priceDetails));
817
963
  setPricesAreLoading(false);
818
964
  } catch (err) {
@@ -822,8 +968,7 @@ const SearchResultsContainer: React.FC = () => {
822
968
  };
823
969
 
824
970
  const fetchItinerary = async () => {
825
- console.log('Fetching itinerary for entry', editablePackagingEntry);
826
- if (!context || !editablePackagingEntry) return;
971
+ if (!context || !context.packagingEntry || !editablePackagingEntry || isEmpty(editablePackagingEntry.lines)) return;
827
972
  setItineraryIsLoading(true);
828
973
  try {
829
974
  const config: TideClientConfig = {
@@ -831,7 +976,15 @@ const SearchResultsContainer: React.FC = () => {
831
976
  apiKey: context.tideConnection.apiKey
832
977
  };
833
978
 
834
- const itinerary = await getItinerary(config, editablePackagingEntry);
979
+ const request = {
980
+ language: context.languageCode ?? 'en-GB',
981
+ officeId: context.tideConnection.officeId,
982
+ catalogueId: context.searchConfiguration.defaultCatalogueId ?? 0,
983
+ agentId: context.agentId,
984
+ payload: editablePackagingEntry
985
+ } as PackagingRequestBase<PackagingEntry>;
986
+
987
+ const itinerary = await getItinerary(config, request);
835
988
  console.log('Fetched itinerary', itinerary);
836
989
  dispatch(setItinerary(itinerary));
837
990
  setItineraryIsLoading(false);
@@ -845,6 +998,379 @@ const SearchResultsContainer: React.FC = () => {
845
998
  fetchItinerary();
846
999
  }, [editablePackagingEntry]);
847
1000
 
1001
+ // Flight selection
1002
+ const [selectedOutwardKey, setSelectedOutwardKey] = useState<string | null>(null);
1003
+ const [selectedReturnKey, setSelectedReturnKey] = useState<string | null>(null);
1004
+
1005
+ const uniqueOutwardFlights: PackagingFlightResponse[] = React.useMemo(() => {
1006
+ const map = new Map();
1007
+
1008
+ packagingFlightResults.forEach((flight) => {
1009
+ const key = getFlightKey(flight.outward.segments);
1010
+
1011
+ if (!map.has(key)) {
1012
+ map.set(key, flight);
1013
+ }
1014
+ });
1015
+
1016
+ return Array.from(map.values());
1017
+ }, [packagingFlightResults]);
1018
+
1019
+ const [uniqueReturnFlights, setUniqueReturnFlights] = useState<PackagingFlightResponse[]>([]);
1020
+
1021
+ useEffect(() => {
1022
+ if (!selectedOutwardKey) {
1023
+ setUniqueReturnFlights([]);
1024
+ setSelectedReturnKey(null);
1025
+ return;
1026
+ }
1027
+
1028
+ // Filter combinations that match selected outward fare
1029
+ const matchingCombinations = packagingFlightResults.filter((flight) => getFlightKey(flight.outward.segments) === selectedOutwardKey);
1030
+ const returnMap = new Map();
1031
+
1032
+ matchingCombinations.forEach((flight) => {
1033
+ const key = getFlightKey(flight.return.segments);
1034
+
1035
+ if (!returnMap.has(key)) {
1036
+ returnMap.set(key, flight);
1037
+ }
1038
+ });
1039
+
1040
+ const returns = Array.from(returnMap.values());
1041
+
1042
+ setUniqueReturnFlights(returns);
1043
+ const firstReturnKey = returns.length > 0 ? getFlightKey(first(returns)?.return.segments) : null;
1044
+
1045
+ if (firstReturnKey) {
1046
+ setSelectedReturnKey(firstReturnKey);
1047
+ }
1048
+ }, [selectedOutwardKey, packagingFlightResults]);
1049
+
1050
+ const selectedOutward = React.useMemo(() => {
1051
+ if (!selectedOutwardKey) return null;
1052
+
1053
+ return packagingFlightResults.find((flight) => getFlightKey(flight.outward.segments) === selectedOutwardKey) || null;
1054
+ }, [packagingFlightResults, selectedOutwardKey]);
1055
+
1056
+ const selectedReturn = React.useMemo(() => {
1057
+ if (!selectedReturnKey) return null;
1058
+
1059
+ return packagingFlightResults.find((flight) => getFlightKey(flight.return.segments) === selectedReturnKey) || null;
1060
+ }, [packagingFlightResults, selectedReturnKey]);
1061
+
1062
+ const selectedCombinationFlight = React.useMemo(() => {
1063
+ if (!selectedOutwardKey || !selectedReturnKey) return undefined;
1064
+
1065
+ return packagingFlightResults.find(
1066
+ (flight) => getFlightKey(flight.outward.segments) === selectedOutwardKey && getFlightKey(flight.return.segments) === selectedReturnKey
1067
+ );
1068
+ }, [packagingFlightResults, selectedOutwardKey, selectedReturnKey]);
1069
+
1070
+ // TODO: get details for selected combination flight and show in fly-in
1071
+ // useEffect(() => {
1072
+ // if (!selectedCombinationFlight) return;
1073
+
1074
+ // dispatch(setSelectedPackagingFlight(selectedCombinationFlight));
1075
+ // // onFlightSearch(selectedCombinationFlight); // Trigger search to update accommodation options based on selected flight
1076
+ // dispatch(setFlyInIsOpen(true));
1077
+ // }, [selectedCombinationFlight, dispatch]);
1078
+
1079
+ useEffect(() => {
1080
+ if (!context) return;
1081
+
1082
+ const seed = activeSearchSeed;
1083
+ if (!seed) return;
1084
+
1085
+ const nextEntry = buildOrUpdatePackagingEntryPartial({
1086
+ sourceEntry: editablePackagingEntry ?? context.packagingEntry ?? null,
1087
+ selectedHotelCode: selectedPackagingAccoResultCode,
1088
+ accommodationResults: packagingAccoResults,
1089
+ selectedFlight: selectedCombinationFlight ?? null,
1090
+ seed,
1091
+ transactionId: transactionId ?? context.packagingEntry?.transactionId ?? '',
1092
+ language: context.languageCode ?? 'en-GB'
1093
+ });
1094
+
1095
+ if (!nextEntry) return;
1096
+
1097
+ dispatch(setEditablePackagingEntry(nextEntry));
1098
+
1099
+ if (selectedCombinationFlight) {
1100
+ dispatch(setSelectedPackagingFlight(selectedCombinationFlight));
1101
+ }
1102
+ }, [
1103
+ context,
1104
+ activeSearchSeed,
1105
+ selectedPackagingAccoResultCode,
1106
+ packagingAccoResults,
1107
+ packagingAccoSearchDetails,
1108
+ selectedCombinationFlight,
1109
+ transactionId,
1110
+ dispatch
1111
+ ]);
1112
+
1113
+ const removeAccommodationLines = (lines: PackagingEntryLine[]) => lines.filter((line) => line.serviceType !== ACCOMMODATION_SERVICE_TYPE);
1114
+
1115
+ const removeFlightLines = (lines: PackagingEntryLine[]) => lines.filter((line) => line.serviceType !== FLIGHT_SERVICE_TYPE);
1116
+
1117
+ const buildAccommodationLinesFromSelection = (selectedHotel: PackagingAccommodationResponse, seed: SearchSeed): PackagingEntryLine[] => {
1118
+ if (!selectedHotel) return [];
1119
+ const parentGuid = crypto.randomUUID();
1120
+
1121
+ return selectedHotel.rooms
1122
+ .filter((room) => room.options.some((o) => o.isSelected))
1123
+ .map((room, index) => {
1124
+ const option = room.options.find((o) => o.isSelected)!;
1125
+
1126
+ const pax =
1127
+ seed.rooms?.flatMap((room, roomIndex) =>
1128
+ room.pax.map((p, paxIndex) => ({
1129
+ paxId: p.id,
1130
+ room: roomIndex,
1131
+ order: paxIndex
1132
+ }))
1133
+ ) ?? [];
1134
+
1135
+ return {
1136
+ guid: option.guid ?? crypto.randomUUID(),
1137
+ moment: '',
1138
+ parentGuid: index === 0 ? null : parentGuid,
1139
+ order: index,
1140
+ isChanged: true,
1141
+ from: selectedHotel.fromDate,
1142
+ to: selectedHotel.toDate,
1143
+ serviceType: ACCOMMODATION_SERVICE_TYPE,
1144
+ productName: selectedHotel.name,
1145
+ productCode: selectedHotel.code,
1146
+ accommodationName: option.accommodationName,
1147
+ accommodationCode: option.accommodationCode,
1148
+ regimeName: option.regimeName,
1149
+ regimeCode: option.regimeCode,
1150
+ country: selectedHotel.countryId ? { id: selectedHotel.countryId, name: selectedHotel.countryName, localizations: [] } : null,
1151
+ region: selectedHotel.regionId ? { id: selectedHotel.regionId, name: selectedHotel.regionName, localizations: [] } : null,
1152
+ oord: selectedHotel.oordId ? { id: selectedHotel.oordId, name: selectedHotel.oordName, localizations: [] } : null,
1153
+ location: selectedHotel.locationId ? { id: selectedHotel.locationId, name: selectedHotel.locationName, localizations: [] } : null,
1154
+ longitude: selectedHotel.longitude ?? null,
1155
+ latitude: selectedHotel.latitude ?? null,
1156
+ pax,
1157
+ flightInformation: null
1158
+ } satisfies PackagingEntryLine;
1159
+ });
1160
+ };
1161
+
1162
+ const toDateOnlyUtcString = (value: string | Date): string => {
1163
+ const date = new Date(value);
1164
+
1165
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())).toISOString();
1166
+ };
1167
+
1168
+ const toTimeOnlyString = (value: string | Date): string => {
1169
+ const date = new Date(value);
1170
+
1171
+ const hh = String(date.getUTCHours()).padStart(2, '0');
1172
+ const mm = String(date.getUTCMinutes()).padStart(2, '0');
1173
+ const ss = String(date.getUTCSeconds()).padStart(2, '0');
1174
+
1175
+ return `${hh}:${mm}:${ss}`;
1176
+ };
1177
+
1178
+ const mapFlightSegmentsToFlightLines = (segments: FlightSearchResponseFlightSegment[]): PackagingEntryLineFlightLine[] =>
1179
+ segments.map((segment) => ({
1180
+ airlineCode: segment.marketingAirlineCode,
1181
+ airlineDescription: segment.marketingAirlineName,
1182
+ operatingAirlineCode: segment.operatingAirlineCode,
1183
+ operatingAirlineDescription: segment.operatingAirlineName,
1184
+ flightNumber: segment.flightNumber,
1185
+ operatingFlightNumber: segment.operatingFlightNumber ?? null,
1186
+ departureDate: toDateOnlyUtcString(segment.departureDateTime),
1187
+ departureTime: toTimeOnlyString(segment.departureDateTime),
1188
+ departureAirportCode: segment.departureAirportCode,
1189
+ departureAirportDescription: segment.departureAirportName,
1190
+ arrivalDate: toDateOnlyUtcString(segment.arrivalDateTime),
1191
+ arrivalTime: toTimeOnlyString(segment.arrivalDateTime),
1192
+ arrivalAirportCode: segment.arrivalAirportCode,
1193
+ arrivalAirportDescription: segment.arrivalAirportName,
1194
+ durationInTicks: segment.durationInTicks
1195
+ }));
1196
+
1197
+ const buildFlightLabel = (segments: FlightSearchResponseFlightSegment[]) => {
1198
+ const firstSegment = first(segments);
1199
+ const lastSegment = last(segments);
1200
+
1201
+ if (!firstSegment || !lastSegment) {
1202
+ return { productName: 'Flight', productCode: 'FLIGHT' };
1203
+ }
1204
+
1205
+ return {
1206
+ productName: `${firstSegment.departureAirportName} - ${lastSegment.arrivalAirportName} (${firstSegment.marketingAirlineName})`,
1207
+ productCode: `${firstSegment.departureAirportCode} ${lastSegment.arrivalAirportCode}/${firstSegment.marketingAirlineCode}`
1208
+ };
1209
+ };
1210
+
1211
+ const buildFlightLinesFromSelection = (selectedFlight: PackagingFlightResponse): PackagingEntryLine[] => {
1212
+ if (!selectedFlight) return [];
1213
+
1214
+ const outwardSegments = selectedFlight.outward?.segments ?? [];
1215
+ const returnSegments = selectedFlight.return?.segments ?? [];
1216
+
1217
+ if (!outwardSegments.length || !returnSegments.length) return [];
1218
+
1219
+ const outwardLabel = buildFlightLabel(outwardSegments);
1220
+ const returnLabel = buildFlightLabel(returnSegments);
1221
+
1222
+ const outwardFirst = first(outwardSegments);
1223
+ const outwardLast = last(outwardSegments);
1224
+ const returnFirst = first(returnSegments);
1225
+ const returnLast = last(returnSegments);
1226
+
1227
+ const outwardLine = {
1228
+ guid: selectedFlight.outwardGuid,
1229
+ parentGuid: null,
1230
+ order: 0,
1231
+ isChanged: true,
1232
+ from: outwardFirst?.departureDateTime.toString() ?? '',
1233
+ to: outwardLast?.arrivalDateTime.toString() ?? '',
1234
+ serviceType: FLIGHT_SERVICE_TYPE,
1235
+ productName: outwardLabel.productName,
1236
+ productCode: outwardLabel.productCode,
1237
+ accommodationName: outwardFirst?.metaData?.farePriceClassName,
1238
+ accommodationCode: outwardFirst?.metaData?.fareCode,
1239
+ regimeName: null,
1240
+ regimeCode: null,
1241
+ country: null,
1242
+ region: null,
1243
+ oord: null,
1244
+ location: null,
1245
+ longitude: null,
1246
+ latitude: null,
1247
+ // pax: allPaxAssignments,
1248
+ flightInformation: {
1249
+ pnr: '',
1250
+ flightLines: mapFlightSegmentsToFlightLines(outwardSegments)
1251
+ }
1252
+ } as PackagingEntryLine;
1253
+
1254
+ const returnLine = {
1255
+ guid: selectedFlight.returnGuid,
1256
+ parentGuid: selectedFlight.outwardGuid,
1257
+ order: 1,
1258
+ isChanged: true,
1259
+ from: returnFirst?.departureDateTime.toString() ?? '',
1260
+ to: returnLast?.departureDateTime.toString() ?? '',
1261
+ serviceType: FLIGHT_SERVICE_TYPE,
1262
+ productName: returnLabel.productName,
1263
+ productCode: returnLabel.productCode,
1264
+ accommodationName: returnFirst?.metaData?.farePriceClassName,
1265
+ accommodationCode: returnFirst?.metaData?.fareCode,
1266
+ regimeName: null,
1267
+ regimeCode: null,
1268
+ country: null,
1269
+ region: null,
1270
+ oord: null,
1271
+ location: null,
1272
+ longitude: null,
1273
+ latitude: null,
1274
+ // pax: allPaxAssignments,
1275
+ flightInformation: {
1276
+ pnr: '',
1277
+ flightLines: mapFlightSegmentsToFlightLines(returnSegments)
1278
+ }
1279
+ } as PackagingEntryLine;
1280
+
1281
+ return [outwardLine, returnLine];
1282
+ };
1283
+
1284
+ const buildOrUpdatePackagingEntryPartial = ({
1285
+ sourceEntry,
1286
+ selectedHotelCode,
1287
+ accommodationResults,
1288
+ selectedFlight,
1289
+ seed,
1290
+ transactionId,
1291
+ language
1292
+ }: BuildPackagingEntryPartialArgs): PackagingEntry | null => {
1293
+ if (!seed?.rooms?.length) return null;
1294
+
1295
+ const entry = buildBasePackagingEntry(sourceEntry, seed, transactionId, language);
1296
+
1297
+ let nextLines = [...(entry.lines ?? [])];
1298
+
1299
+ const selectedHotel = selectedHotelCode ? accommodationResults.find((r) => r.code === selectedHotelCode) : null;
1300
+
1301
+ // Update accommodation only when enough data exists
1302
+ if (selectedHotel) {
1303
+ const accommodationLines = buildAccommodationLinesFromSelection(selectedHotel, seed);
1304
+
1305
+ if (accommodationLines.length) {
1306
+ nextLines = removeAccommodationLines(nextLines);
1307
+ nextLines = [...nextLines, ...accommodationLines];
1308
+ }
1309
+ }
1310
+
1311
+ // Update flights only when full selected combination exists
1312
+ if (selectedFlight) {
1313
+ const flightLines = buildFlightLinesFromSelection(selectedFlight);
1314
+ if (flightLines.length) {
1315
+ nextLines = removeFlightLines(nextLines);
1316
+ nextLines = [...nextLines, ...flightLines];
1317
+ }
1318
+ }
1319
+
1320
+ nextLines = nextLines.map((line, index) => ({
1321
+ ...line,
1322
+ order: index
1323
+ }));
1324
+
1325
+ return {
1326
+ ...entry,
1327
+ language,
1328
+ transactionId,
1329
+ pax: entry.pax,
1330
+ lines: nextLines
1331
+ };
1332
+ };
1333
+
1334
+ const buildBasePackagingEntry = (
1335
+ sourceEntry: PackagingEntry | null | undefined,
1336
+ seed: SearchSeed,
1337
+ transactionId: string,
1338
+ language: string
1339
+ ): PackagingEntry => {
1340
+ if (sourceEntry) {
1341
+ return structuredClone(sourceEntry);
1342
+ }
1343
+
1344
+ let paxId = 1;
1345
+
1346
+ const pax =
1347
+ seed.rooms?.flatMap((room, roomIndex) =>
1348
+ room.pax.map((_, paxIndex) => ({
1349
+ id: paxId++,
1350
+ firstName: '',
1351
+ lastName: '',
1352
+ dateOfBirth: null,
1353
+ isMainBooker: roomIndex === 0 && paxIndex === 0
1354
+ }))
1355
+ ) ?? [];
1356
+
1357
+ return {
1358
+ language,
1359
+ transactionId,
1360
+ dossierNumber: '',
1361
+ status: 0,
1362
+ bookingDate: null,
1363
+ price: 0,
1364
+ depositAmount: 0,
1365
+ pax,
1366
+ lines: []
1367
+ } as PackagingEntry;
1368
+ };
1369
+
1370
+ const visibleOutwardFlights = React.useMemo(() => {
1371
+ return showAllOutwardFlights ? uniqueOutwardFlights : uniqueOutwardFlights.slice(0, 3);
1372
+ }, [showAllOutwardFlights, uniqueOutwardFlights]);
1373
+
848
1374
  return (
849
1375
  <div id="tide-booking" className="search__bg">
850
1376
  {context && (
@@ -920,11 +1446,9 @@ const SearchResultsContainer: React.FC = () => {
920
1446
  )}
921
1447
  <div className="search__result-row">
922
1448
  <span className="search__result-row-text">
923
- {!isLoading && !context.packagingEntry && (
1449
+ {!isLoading && context.searchConfiguration.qsmType !== PortalQsmType.AccommodationAndFlight && (
924
1450
  <>
925
- {((context.searchConfiguration.qsmType === PortalQsmType.AccommodationAndFlight &&
926
- context.searchConfiguration.enableManualPackaging) ||
927
- context.searchConfiguration.qsmType === PortalQsmType.Accommodation) &&
1451
+ {context.searchConfiguration.qsmType === PortalQsmType.Accommodation &&
928
1452
  filteredPackagingAccoResults?.length &&
929
1453
  filteredPackagingAccoResults?.length}
930
1454
  {context.searchConfiguration.qsmType !== PortalQsmType.Accommodation && filteredResults?.length && filteredResults.length}
@@ -957,17 +1481,105 @@ const SearchResultsContainer: React.FC = () => {
957
1481
 
958
1482
  {context.searchConfiguration.qsmType === PortalQsmType.GroupTour && <GroupTourResults isLoading={isLoading} />}
959
1483
 
960
- {context.searchConfiguration.qsmType === PortalQsmType.AccommodationAndFlight &&
961
- !context.packagingEntry &&
962
- context.showFlightResults &&
963
- bookingPackageDetails?.outwardFlights && <FlightResults flights={bookingPackageDetails?.outwardFlights} isDeparture={true} />}
1484
+ {context.searchConfiguration.qsmType === PortalQsmType.AccommodationAndFlight && !context.packagingEntry && context.showFlightResults && (
1485
+ // bookingPackageDetails?.outwardFlights &&
1486
+ // <FlightResults flights={bookingPackageDetails?.outwardFlights} isDeparture={true} />
1487
+ <>
1488
+ <div className="search__results__label search__results__label--secondary">
1489
+ <div className="search__results__label__date">
1490
+ <Icon name="ui-flight" height={16} fill="white" />
1491
+ </div>
1492
+ <div className="search__results__label__text">
1493
+ <h3>
1494
+ {translations.SRP.SELECT} <strong> {translations.SRP.DEPARTURE}</strong>
1495
+ </h3>
1496
+ </div>
1497
+ </div>
1498
+
1499
+ {flightsLoading ? (
1500
+ <Spinner />
1501
+ ) : (
1502
+ <>
1503
+ <div className="search__results__cards search__results__cards--extended">
1504
+ {selectedOutwardKey && selectedOutward && (
1505
+ <IndependentFlightOption
1506
+ key={`flight-${selectedOutwardKey}`}
1507
+ item={selectedOutward.outward}
1508
+ guid={selectedOutward.outwardGuid}
1509
+ onSelect={() => setSelectedOutwardKey(null)}
1510
+ selectedGuid={selectedOutward.outwardGuid}
1511
+ isOutward={true}
1512
+ showSelectedState={true}
1513
+ price={selectedOutward.price}
1514
+ />
1515
+ )}
1516
+ {visibleOutwardFlights.map((result) => (
1517
+ <IndependentFlightOption
1518
+ key={`flight-${result.outwardGuid}`}
1519
+ item={result.outward}
1520
+ onSelect={() => setSelectedOutwardKey(getFlightKey(result.outward.segments))}
1521
+ guid={result.outwardGuid}
1522
+ isOutward={true}
1523
+ price={result.price}
1524
+ currentSelectedPrice={selectedOutward?.price}
1525
+ />
1526
+ ))}
1527
+ </div>
1528
+ {uniqueOutwardFlights && uniqueOutwardFlights.length > 3 && (
1529
+ <div className="search__results__cards__actions">
1530
+ <button className="cta cta--secondary" onClick={() => setShowAllOutwardFlights(!showAllOutwardFlights)}>
1531
+ {showAllOutwardFlights ? translations.SRP.SHOW_LESS : translations.SRP.SHOW_MORE}
1532
+ </button>
1533
+ </div>
1534
+ )}
1535
+ </>
1536
+ )}
1537
+ </>
1538
+ )}
964
1539
 
965
1540
  {context.showHotelAccommodationResults && !context.packagingEntry && <HotelAccommodationResults isLoading={isLoading} />}
966
1541
 
967
- {context.searchConfiguration.qsmType === PortalQsmType.AccommodationAndFlight &&
968
- !context.packagingEntry &&
969
- context.showFlightResults &&
970
- bookingPackageDetails?.returnFlights && <FlightResults flights={bookingPackageDetails?.returnFlights} isDeparture={false} />}
1542
+ {context.searchConfiguration.qsmType === PortalQsmType.AccommodationAndFlight && !context.packagingEntry && context.showFlightResults && (
1543
+ // bookingPackageDetails?.returnFlights &&
1544
+ // <FlightResults flights={bookingPackageDetails?.returnFlights} isDeparture={false} />
1545
+ <>
1546
+ <div className="search__results__label search__results__label--secondary">
1547
+ <div className="search__results__label__date">
1548
+ <Icon name="ui-flight" height={16} fill="white" />
1549
+ </div>
1550
+ <div className="search__results__label__text">
1551
+ <h3>
1552
+ {translations.SRP.SELECT} <strong> {translations.SRP.RETURN}</strong>
1553
+ </h3>
1554
+ </div>
1555
+ </div>
1556
+
1557
+ <div className="search__results__cards search__results__cards--extended">
1558
+ {selectedReturnKey && selectedReturn && (
1559
+ <IndependentFlightOption
1560
+ key={`flight-${selectedReturnKey}`}
1561
+ item={selectedReturn.return}
1562
+ guid={selectedReturn.outwardGuid}
1563
+ selectedGuid={selectedReturn.outwardGuid}
1564
+ isOutward={false}
1565
+ showSelectedState={true}
1566
+ price={selectedReturn.price}
1567
+ />
1568
+ )}
1569
+ {uniqueReturnFlights.map((result) => (
1570
+ <IndependentFlightOption
1571
+ key={`flight-${result.outwardGuid}`}
1572
+ item={result.return}
1573
+ onSelect={() => setSelectedReturnKey(getFlightKey(result.return.segments))}
1574
+ guid={result.outwardGuid}
1575
+ isOutward={false}
1576
+ currentSelectedPrice={selectedReturn?.price}
1577
+ price={result.price}
1578
+ />
1579
+ ))}
1580
+ </div>
1581
+ </>
1582
+ )}
971
1583
 
972
1584
  {context.searchConfiguration.qsmType === PortalQsmType.AccommodationAndFlight && context.packagingEntry && itinerary && (
973
1585
  <FullItinerary isLoading={itineraryIsLoading} />