@qite/tide-booking-component 1.4.105 → 1.4.106

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,10 +1,13 @@
1
- import React, { useEffect, useMemo, useRef } from 'react';
1
+ import React, { useContext, useEffect, useMemo, useRef } from 'react';
2
2
  import { useSelector } from 'react-redux';
3
3
  import { SearchResultsRootState } from '../../store/search-results-store';
4
- import { ClientPortalItinerary } from '@qite/tide-client';
4
+ import { ClientPortalItinerary, ClientPortalItineraryItem, ClientPortalItineraryNode } from '@qite/tide-client';
5
5
  import Spinner from '../spinner/spinner';
6
+ import SearchResultsConfigurationContext from '../../search-results-configuration-context';
7
+ import { getTranslations } from '../../../shared/utils/localization-util';
8
+ import { format } from 'date-fns';
6
9
 
7
- const formatNodeDate = (date?: Date | null) => {
10
+ const formatNodeDate = (date?: Date | string | null) => {
8
11
  if (!date) return '';
9
12
 
10
13
  try {
@@ -13,7 +16,7 @@ const formatNodeDate = (date?: Date | null) => {
13
16
  day: '2-digit',
14
17
  month: '2-digit',
15
18
  year: 'numeric'
16
- }).format(date);
19
+ }).format(new Date(date));
17
20
  } catch {
18
21
  return '';
19
22
  }
@@ -25,7 +28,79 @@ const escapeHtml = (value?: string | null) => {
25
28
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
26
29
  };
27
30
 
28
- const buildItineraryHtml = (itinerary?: ClientPortalItinerary | null) => {
31
+ const getNodeDayRange = (node: ClientPortalItineraryNode) => {
32
+ const startDay = Number(node.startDay ?? 0);
33
+ const endDay = Number(node.endDay ?? startDay);
34
+
35
+ return { startDay, endDay };
36
+ };
37
+
38
+ const getItemDuration = (item: ClientPortalItineraryItem) => {
39
+ const duration = Number(item.productDuration ?? 0);
40
+
41
+ return Number.isFinite(duration) && duration > 0 ? duration : 0;
42
+ };
43
+
44
+ const isAccommodationItem = (item: ClientPortalItineraryItem) => {
45
+ return getItemDuration(item) > 1 && item.templateName?.toLowerCase().includes('hotel');
46
+ };
47
+
48
+ const getAccommodationName = (item: ClientPortalItineraryItem) => {
49
+ return item.title || 'Onbekende accommodatie';
50
+ };
51
+
52
+ const getItemDeduplicationKey = (item: ClientPortalItineraryItem) => {
53
+ const templateName = item.templateName?.toLowerCase();
54
+
55
+ // if (templateName === 'hotel') {
56
+ // return [item.templateName, item.productCode, item.accommodationCode, item.regimeCode].filter(Boolean).join('|');
57
+ // }
58
+
59
+ // if (templateName === 'excursion') {
60
+ // return [item.templateName, item.productCode].filter(Boolean).join('|');
61
+ // }
62
+
63
+ return [item.templateName, item.productCode, item.title].filter(Boolean).join('|');
64
+ };
65
+
66
+ const getUniqueItems = (items: ClientPortalItineraryItem[] = []) => {
67
+ const seen = new Set<string>();
68
+
69
+ return items.filter((item) => {
70
+ const key = getItemDeduplicationKey(item) || item.itemGuid;
71
+
72
+ if (seen.has(key)) {
73
+ return false;
74
+ }
75
+
76
+ seen.add(key);
77
+ return true;
78
+ });
79
+ };
80
+
81
+ const findAccommodationForDay = (nodes: ClientPortalItineraryNode[], currentDay: number) => {
82
+ for (const node of nodes ?? []) {
83
+ const { startDay } = getNodeDayRange(node);
84
+ const uniqueItems = getUniqueItems(node.items ?? []);
85
+
86
+ for (const item of uniqueItems) {
87
+ if (!isAccommodationItem(item)) {
88
+ continue;
89
+ }
90
+
91
+ const duration = getItemDuration(item);
92
+ const accommodationEndDay = startDay + duration - 1;
93
+
94
+ if (currentDay >= startDay && currentDay <= accommodationEndDay) {
95
+ return item;
96
+ }
97
+ }
98
+ }
99
+
100
+ return null;
101
+ };
102
+
103
+ const buildItineraryHtml = (itinerary: ClientPortalItinerary | null, translations: any) => {
29
104
  if (!itinerary) {
30
105
  return `
31
106
  <div class="itinerary-shell">
@@ -34,12 +109,30 @@ const buildItineraryHtml = (itinerary?: ClientPortalItinerary | null) => {
34
109
  `;
35
110
  }
36
111
 
37
- const nodesHtml = (itinerary.nodes ?? [])
112
+ const allNodes = itinerary.nodes ?? [];
113
+
114
+ const nodesHtml = allNodes
38
115
  .map((node) => {
39
- const hasItems = Array.isArray(node.items) && node.items.length > 0;
116
+ const { startDay } = getNodeDayRange(node);
117
+ const day = node.startDate ? format(node.startDate, 'd') : null;
118
+ const month = node.startDate ? format(node.startDate, 'MMM') : null;
119
+ const uniqueItems = getUniqueItems(node.items ?? []);
120
+
121
+ const hasItems = uniqueItems.length > 0;
122
+ const hasAccommodationInCurrentNode = uniqueItems.some(isAccommodationItem);
123
+ const activeAccommodation = findAccommodationForDay(allNodes, startDay);
124
+
125
+ const accommodationBanner =
126
+ !hasAccommodationInCurrentNode && activeAccommodation
127
+ ? `
128
+ <div class="itinerary-node__accommodation-banner">
129
+ Accommodatie voor deze dag: <strong>${escapeHtml(getAccommodationName(activeAccommodation))}</strong>
130
+ </div>
131
+ `
132
+ : '';
40
133
 
41
134
  const itemsHtml = hasItems
42
- ? node.items
135
+ ? uniqueItems
43
136
  .map(
44
137
  (item) => `
45
138
  <article class="itinerary-item" data-template="${escapeHtml(item.templateName)}">
@@ -48,19 +141,24 @@ const buildItineraryHtml = (itinerary?: ClientPortalItinerary | null) => {
48
141
  `
49
142
  )
50
143
  .join('')
51
- : `<div class="itinerary-node__empty">Geen items voor deze dag.</div>`;
144
+ : `<div class="itinerary-node__empty">${translations.ITINERARY.NO_ITEMS}</div>`;
52
145
 
53
146
  return `
54
147
  <section class="itinerary-node">
55
148
  <header class="itinerary-node__header">
56
- <div class="itinerary-node__day">Dag ${node.startDay}${node.endDay > node.startDay ? ` - ${node.endDay}` : ''}</div>
57
- <div class="itinerary-node__meta">
149
+
150
+ <div class="itinerary-node__day">
151
+ <p class="itinerary-node__day-day">${day}</p>
152
+ <p class="itinerary-node__day-month">${month}</p>
153
+ </div>
154
+
155
+ <div>
58
156
  <h2 class="itinerary-node__title">${escapeHtml(node.title)}</h2>
59
- <div class="itinerary-node__date">${escapeHtml(formatNodeDate(node.startDate))}</div>
60
157
  </div>
61
158
  </header>
62
159
 
63
160
  <div class="itinerary-node__content">
161
+ ${accommodationBanner}
64
162
  ${itemsHtml}
65
163
  </div>
66
164
  </section>
@@ -73,7 +171,7 @@ const buildItineraryHtml = (itinerary?: ClientPortalItinerary | null) => {
73
171
  ? `
74
172
  <section class="itinerary-default-items">
75
173
  <h2 class="itinerary-default-items__title">Algemene info</h2>
76
- ${itinerary.defaultItems
174
+ ${getUniqueItems(itinerary.defaultItems)
77
175
  .map(
78
176
  (item) => `
79
177
  <article class="itinerary-item" data-template="${escapeHtml(item.templateName)}">
@@ -103,31 +201,36 @@ interface FullItineraryProps {
103
201
  }
104
202
 
105
203
  const FullItinerary: React.FC<FullItineraryProps> = ({ isLoading }) => {
106
- if (isLoading) {
107
- return <Spinner />;
108
- }
109
-
110
204
  const { itinerary } = useSelector((state: SearchResultsRootState) => state.searchResults);
205
+ const context = useContext(SearchResultsConfigurationContext);
206
+ const translations = getTranslations(context?.languageCode ?? 'en-GB');
207
+
111
208
  const hostRef = useRef<HTMLDivElement | null>(null);
112
209
  const shadowRootRef = useRef<ShadowRoot | null>(null);
113
210
 
114
- const html = useMemo(() => buildItineraryHtml(itinerary as ClientPortalItinerary | null), [itinerary]);
211
+ const html = useMemo(() => buildItineraryHtml(itinerary as ClientPortalItinerary | null, translations), [itinerary, translations]);
115
212
 
116
213
  useEffect(() => {
117
- if (!hostRef.current) {
214
+ if (isLoading) {
118
215
  return;
119
216
  }
120
217
 
121
- if (!shadowRootRef.current) {
122
- shadowRootRef.current = hostRef.current.attachShadow({ mode: 'open' });
218
+ const host = hostRef.current;
219
+
220
+ if (!host) {
221
+ return;
123
222
  }
124
223
 
125
- const shadowRoot = shadowRootRef.current;
224
+ const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: 'open' });
225
+
226
+ shadowRootRef.current = shadowRoot;
126
227
 
127
228
  shadowRoot.innerHTML = `
128
229
  <style>
129
230
  :host {
130
231
  all: initial;
232
+ display: block;
233
+ width: 100%;
131
234
  }
132
235
 
133
236
  *,
@@ -169,27 +272,31 @@ const FullItinerary: React.FC<FullItineraryProps> = ({ isLoading }) => {
169
272
  .itinerary-node__header {
170
273
  display: flex;
171
274
  gap: 16px;
172
- align-items: flex-start;
173
- padding: 20px 20px 16px;
275
+ align-items: center;
174
276
  border-bottom: 1px solid #eef0f2;
175
277
  background: #fafafa;
176
278
  }
177
279
 
178
280
  .itinerary-node__day {
179
- flex: 0 0 auto;
180
- min-width: 72px;
181
- padding: 8px 12px;
182
- border-radius: 999px;
183
- font-size: 14px;
184
- font-weight: 700;
185
- line-height: 1;
186
- background: #111827;
187
- color: #fff;
188
- text-align: center;
281
+ flex: 50px 0 0;
282
+ display: flex;
283
+ flex-flow: column;
284
+ align-items: center;
285
+ justify-content: center;
286
+ background: #1f9470;
287
+ color: white;
288
+ border: 1.5px solid var(--tide-booking-color-secondary);
189
289
  }
190
290
 
191
- .itinerary-node__meta {
192
- min-width: 0;
291
+ .itinerary-node__day-month {
292
+ margin: 0px;
293
+ }
294
+
295
+ .itinerary-node__day-day {
296
+ margin: 0px;
297
+ font-weight: var(--tide-booking-search-results-label-date-month-font-weight);
298
+ color: var(--tide-booking-search-results-label-date-month-color);
299
+ font-size: 24px;
193
300
  }
194
301
 
195
302
  .itinerary-node__title {
@@ -198,18 +305,22 @@ const FullItinerary: React.FC<FullItineraryProps> = ({ isLoading }) => {
198
305
  line-height: 1.25;
199
306
  }
200
307
 
201
- .itinerary-node__date {
202
- margin-top: 6px;
203
- font-size: 14px;
204
- color: #6b7280;
205
- }
206
-
207
308
  .itinerary-node__content {
208
309
  display: grid;
209
310
  gap: 20px;
210
311
  padding: 20px;
211
312
  }
212
313
 
314
+ .itinerary-node__accommodation-banner {
315
+ padding: 12px 16px;
316
+ border: 1px solid #dbeafe;
317
+ border-radius: 12px;
318
+ background: #eff6ff;
319
+ color: #1e3a8a;
320
+ font-size: 14px;
321
+ line-height: 1.4;
322
+ }
323
+
213
324
  .itinerary-item {
214
325
  display: block;
215
326
  border-radius: 12px;
@@ -243,14 +354,6 @@ const FullItinerary: React.FC<FullItineraryProps> = ({ isLoading }) => {
243
354
  .itinerary-shell {
244
355
  padding: 16px;
245
356
  }
246
-
247
- .itinerary-node__header {
248
- flex-direction: column;
249
- }
250
-
251
- .itinerary-node__day {
252
- min-width: auto;
253
- }
254
357
  }
255
358
 
256
359
  ${itinerary?.styleSheetBody ?? ''}
@@ -258,9 +361,14 @@ const FullItinerary: React.FC<FullItineraryProps> = ({ isLoading }) => {
258
361
 
259
362
  ${html}
260
363
  `;
261
- }, [html, itinerary?.styleSheetBody]);
262
-
263
- return <div ref={hostRef} />;
364
+ }, [html, itinerary?.styleSheetBody, isLoading]);
365
+
366
+ return (
367
+ <>
368
+ {isLoading && <Spinner />}
369
+ <div ref={hostRef} style={{ display: isLoading ? 'none' : 'block' }} />
370
+ </>
371
+ );
264
372
  };
265
373
 
266
374
  export default FullItinerary;
@@ -102,6 +102,25 @@ const getSegmentTitle = (segment: PackagingEntryLine) => {
102
102
  return segment.productName ?? segment.accommodationName;
103
103
  };
104
104
 
105
+ const SERVICE_TYPE_PRIORITY: Record<number, number> = {
106
+ 7: 0, // Flight
107
+ 13: 1, // Transfer
108
+ 3: 2, // Hotel
109
+ 4: 3 // Excursion
110
+ };
111
+
112
+ const getServiceTypePriority = (serviceType?: number) => {
113
+ return SERVICE_TYPE_PRIORITY[serviceType ?? -1] ?? 2;
114
+ };
115
+
116
+ const getDateOnlyTime = (date?: string | Date | null) => {
117
+ if (!date) return 0;
118
+
119
+ const parsedDate = new Date(date);
120
+
121
+ return new Date(parsedDate.getFullYear(), parsedDate.getMonth(), parsedDate.getDate()).getTime();
122
+ };
123
+
105
124
  const Itinerary: React.FC<ItineraryProps> = ({ isOpen, handleSetIsOpen, isLoading, onEditAccommodation }) => {
106
125
  const context = useContext(SearchResultsConfigurationContext);
107
126
  const translations = getTranslations(context?.languageCode ?? 'en-GB');
@@ -111,16 +130,21 @@ const Itinerary: React.FC<ItineraryProps> = ({ isOpen, handleSetIsOpen, isLoadin
111
130
 
112
131
  const sortedLines = useMemo(() => {
113
132
  return [...(packagingEntry?.lines ?? [])].sort((a, b) => {
114
- const orderA = a.order ?? Infinity;
115
- const orderB = b.order ?? Infinity;
133
+ const dateA = getDateOnlyTime(a.from);
134
+ const dateB = getDateOnlyTime(b.from);
135
+
136
+ if (dateA !== dateB) {
137
+ return dateA - dateB;
138
+ }
139
+
140
+ const priorityA = getServiceTypePriority(a.serviceType);
141
+ const priorityB = getServiceTypePriority(b.serviceType);
116
142
 
117
- // First sort by order
118
- if (orderA !== orderB) {
119
- return orderA - orderB;
143
+ if (priorityA !== priorityB) {
144
+ return priorityA - priorityB;
120
145
  }
121
146
 
122
- // Fallback to date
123
- return new Date(a.from).getTime() - new Date(b.from).getTime();
147
+ return (a.order ?? Infinity) - (b.order ?? Infinity);
124
148
  });
125
149
  }, [packagingEntry]);
126
150
 
@@ -1108,7 +1108,7 @@ const SearchResultsContainer: React.FC = () => {
1108
1108
  transactionId: transactionId ?? context.packagingEntry?.transactionId ?? '',
1109
1109
  language: context.languageCode ?? 'en-GB'
1110
1110
  });
1111
- console.log('Built nextEntry', nextEntry);
1111
+
1112
1112
  if (!nextEntry) return;
1113
1113
 
1114
1114
  dispatch(setEditablePackagingEntry(nextEntry));
@@ -1658,7 +1658,7 @@ const SearchResultsContainer: React.FC = () => {
1658
1658
  </>
1659
1659
  )}
1660
1660
 
1661
- {context.searchConfiguration.qsmType === PortalQsmType.AccommodationAndFlight && context.packagingEntry && itinerary && (
1661
+ {context.searchConfiguration.qsmType === PortalQsmType.AccommodationAndFlight && context.packagingEntry && (
1662
1662
  <FullItinerary isLoading={itineraryIsLoading} />
1663
1663
  )}
1664
1664
  </div>
@@ -382,5 +382,9 @@
382
382
  "DURATION_DESC": "المدة تنازلياً",
383
383
  "TRAVEL_GROUP": "مجموعة المسافرين",
384
384
  "EXCURSION": "رحلة"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "اليوم",
388
+ "NO_ITEMS": "لا توجد عناصر لهذا اليوم."
385
389
  }
386
390
  }
@@ -382,5 +382,9 @@
382
382
  "DURATION_DESC": "Varighed faldende",
383
383
  "TRAVEL_GROUP": "Rejseselskab",
384
384
  "EXCURSION": "Udflugt"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Dag",
388
+ "NO_ITEMS": "Ingen elementer for denne dag."
385
389
  }
386
390
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Preis",
383
383
  "TRAVEL_GROUP": "Reisegruppe",
384
384
  "EXCURSION": "Ausflug"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Tag",
388
+ "NO_ITEMS": "Keine Elemente für diesen Tag."
385
389
  }
386
390
  }
@@ -386,5 +386,9 @@
386
386
  "ARRIVAL_AIRPORTS": "Arrival airports",
387
387
  "TRAVEL_GROUP": "Travel group",
388
388
  "EXCURSION": "Excursion"
389
+ },
390
+ "ITINERARY": {
391
+ "DAY": "Day",
392
+ "NO_ITEMS": "No items for this day."
389
393
  }
390
394
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Precio",
383
383
  "TRAVEL_GROUP": "Grupo de viaje",
384
384
  "EXCURSION": "Excursión"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Día",
388
+ "NO_ITEMS": "No hay elementos para este día."
385
389
  }
386
390
  }
@@ -386,5 +386,9 @@
386
386
  "PRICE": "Prix",
387
387
  "TRAVEL_GROUP": "Groupe de voyageurs",
388
388
  "EXCURSION": "Excursion"
389
+ },
390
+ "ITINERARY": {
391
+ "DAY": "Jour",
392
+ "NO_ITEMS": "Aucun élément pour ce jour."
389
393
  }
390
394
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Prix",
383
383
  "TRAVEL_GROUP": "Groupe de voyageurs",
384
384
  "EXCURSION": "Excursion"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Jour",
388
+ "NO_ITEMS": "Aucun élément pour ce jour."
385
389
  }
386
390
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Verð",
383
383
  "TRAVEL_GROUP": "Ferðahópur",
384
384
  "EXCURSION": "Útflutningur"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Dagur",
388
+ "NO_ITEMS": "Engar upplýsingar fyrir þennan dag."
385
389
  }
386
390
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Prezzo",
383
383
  "TRAVEL_GROUP": "Gruppo di viaggio",
384
384
  "EXCURSION": "Escursione"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Giorno",
388
+ "NO_ITEMS": "Nessun elemento per questo giorno."
385
389
  }
386
390
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "価格",
383
383
  "TRAVEL_GROUP": "旅行グループ",
384
384
  "EXCURSION": "エクスカーション"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "日",
388
+ "NO_ITEMS": "この日のアイテムはありません。"
385
389
  }
386
390
  }
@@ -386,5 +386,9 @@
386
386
  "PRICE": "Prijs",
387
387
  "TRAVEL_GROUP": "Reisgezelschap",
388
388
  "EXCURSION": "Excursie"
389
+ },
390
+ "ITINERARY": {
391
+ "DAY": "Dag",
392
+ "NO_ITEMS": "Geen elementen voor deze dag."
389
393
  }
390
394
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Prijs",
383
383
  "TRAVEL_GROUP": "Reisgezelschap",
384
384
  "EXCURSION": "Excursie"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Dag",
388
+ "NO_ITEMS": "Geen elementen voor deze dag."
385
389
  }
386
390
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Pris",
383
383
  "TRAVEL_GROUP": "Reisefølge",
384
384
  "EXCURSION": "Utflukt"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Dag",
388
+ "NO_ITEMS": "Ingen elementer for denne dagen."
385
389
  }
386
390
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Cena",
383
383
  "TRAVEL_GROUP": "Grupa podróżnych",
384
384
  "EXCURSION": "Wycieczka"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Dzień",
388
+ "NO_ITEMS": "Brak elementów dla tego dnia."
385
389
  }
386
390
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Preço",
383
383
  "TRAVEL_GROUP": "Grupo de viajantes",
384
384
  "EXCURSION": "Excursão"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Dia",
388
+ "NO_ITEMS": "Nenhum item para este dia."
385
389
  }
386
390
  }
@@ -382,5 +382,9 @@
382
382
  "PRICE": "Pris",
383
383
  "TRAVEL_GROUP": "Resesällskap",
384
384
  "EXCURSION": "Utflykt"
385
+ },
386
+ "ITINERARY": {
387
+ "DAY": "Dag",
388
+ "NO_ITEMS": "Inga element för denna dag."
385
389
  }
386
390
  }