@ticketboothapp/booking 1.2.72 → 1.2.74
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.
package/package.json
CHANGED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/* Root class for scoping - nested selectors override Tailwind */
|
|
2
|
+
.overlay {
|
|
3
|
+
position: fixed;
|
|
4
|
+
inset: 0;
|
|
5
|
+
background: rgba(0, 0, 0, 0.5);
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
justify-content: center;
|
|
9
|
+
z-index: 1000;
|
|
10
|
+
padding: 1rem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.modal {
|
|
14
|
+
background: var(--primary-background, #fff);
|
|
15
|
+
border-radius: 24px;
|
|
16
|
+
max-width: 900px;
|
|
17
|
+
width: 100%;
|
|
18
|
+
max-height: 90vh;
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.pickupDialogRoot .header {
|
|
26
|
+
display: grid !important;
|
|
27
|
+
grid-template-columns: auto 1fr auto !important;
|
|
28
|
+
align-items: center !important;
|
|
29
|
+
gap: 0.75rem !important;
|
|
30
|
+
padding: 1rem 0.75rem !important;
|
|
31
|
+
border-bottom: 1px solid #e5e7eb !important;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.pickupDialogRoot .headerSpacer {
|
|
35
|
+
display: block;
|
|
36
|
+
width: 2.5rem;
|
|
37
|
+
height: 2.5rem;
|
|
38
|
+
flex-shrink: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.pickupDialogRoot .title {
|
|
42
|
+
font-family: 'Poppins', sans-serif !important;
|
|
43
|
+
font-weight: 800 !important;
|
|
44
|
+
font-size: 1.5rem !important;
|
|
45
|
+
color: var(--accent-orange, #e85a2e) !important;
|
|
46
|
+
margin: 0 !important;
|
|
47
|
+
justify-self: center !important;
|
|
48
|
+
text-align: center !important;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.pickupDialogRoot .closeBtn {
|
|
52
|
+
background: none !important;
|
|
53
|
+
border: none !important;
|
|
54
|
+
padding: 0.5rem !important;
|
|
55
|
+
cursor: pointer !important;
|
|
56
|
+
color: var(--grey-text, #6b7280) !important;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.pickupDialogRoot .closeBtn:hover {
|
|
60
|
+
color: var(--primary-text, #1f2937) !important;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.pickupDialogRoot .content {
|
|
64
|
+
padding: var(--spacing-large);
|
|
65
|
+
overflow-y: auto;
|
|
66
|
+
overflow-x: hidden;
|
|
67
|
+
flex: 1;
|
|
68
|
+
min-width: 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.pickupDialogRoot .loading,
|
|
72
|
+
.pickupDialogRoot .error,
|
|
73
|
+
.pickupDialogRoot .empty {
|
|
74
|
+
text-align: center;
|
|
75
|
+
padding: 2rem;
|
|
76
|
+
color: var(--grey-text, #6b7280);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.pickupDialogRoot .error {
|
|
80
|
+
color: #dc2626;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Current pickup section - shown when dialog opens */
|
|
84
|
+
.pickupDialogRoot .currentPickupSection {
|
|
85
|
+
margin-bottom: 1.25rem;
|
|
86
|
+
padding: 1rem;
|
|
87
|
+
background: var(--accent-orange-10, rgba(0, 0, 0, 0.04));
|
|
88
|
+
border-radius: 12px;
|
|
89
|
+
border: 1px solid var(--accent-orange-10, rgba(0, 0, 0, 0.08));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.pickupDialogRoot .currentPickupLabel {
|
|
93
|
+
font-size: 0.75rem;
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
text-transform: uppercase;
|
|
96
|
+
letter-spacing: 0.05em;
|
|
97
|
+
color: var(--grey-text, #6b7280);
|
|
98
|
+
margin: 0 0 0.25rem 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.pickupDialogRoot .currentPickupName {
|
|
102
|
+
font-size: 1rem;
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
color: var(--primary-text, #1f2937);
|
|
105
|
+
margin: 0 0 0.25rem 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.pickupDialogRoot .currentPickupTimes {
|
|
109
|
+
font-size: 0.875rem;
|
|
110
|
+
color: var(--grey-text, #6b7280);
|
|
111
|
+
margin: 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.pickupDialogRoot .selectorWrapper {
|
|
115
|
+
margin-bottom: 1.5rem;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Prevent input overflow - constrain width in flex/grid layouts */
|
|
119
|
+
.pickupDialogRoot .selectorWrapperConstrained {
|
|
120
|
+
min-width: 0;
|
|
121
|
+
overflow: hidden;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.pickupDialogRoot .selectorWrapperConstrained > div {
|
|
125
|
+
min-width: 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* Override Tailwind resets for input and filter pills when inside dialog */
|
|
129
|
+
.pickupDialogRoot .selectorWrapper input[data-pickup-search],
|
|
130
|
+
.pickupDialogRoot .selectorWrapper input[type="text"] {
|
|
131
|
+
width: 100% !important;
|
|
132
|
+
max-width: 100% !important;
|
|
133
|
+
box-sizing: border-box !important;
|
|
134
|
+
padding: 0.5rem 1rem 0.5rem 2.5rem !important;
|
|
135
|
+
font-family: 'Figtree', sans-serif !important;
|
|
136
|
+
font-size: 1rem !important;
|
|
137
|
+
line-height: 1.5 !important;
|
|
138
|
+
color: #1c1917 !important;
|
|
139
|
+
background-color: #fff !important;
|
|
140
|
+
border: 1px solid #d6d3d1 !important;
|
|
141
|
+
border-radius: 0.5rem !important;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.pickupDialogRoot .selectorWrapper input::placeholder {
|
|
145
|
+
color: #a8a29e !important;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.pickupDialogRoot .selectorWrapper input:focus {
|
|
149
|
+
outline: none !important;
|
|
150
|
+
border-color: #78716c !important;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* Filter pills - match booking flow (PrivateShuttleBookingFlow / CheckoutForm) */
|
|
154
|
+
.pickupDialogRoot .selectorWrapper [class*="filterPillsScroll"] {
|
|
155
|
+
display: flex !important;
|
|
156
|
+
flex-wrap: nowrap !important;
|
|
157
|
+
align-items: center !important;
|
|
158
|
+
gap: 0.5rem !important;
|
|
159
|
+
margin-bottom: 1rem !important;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.pickupDialogRoot .selectorWrapper [class*="filterPillsScroll"] > div:first-child {
|
|
163
|
+
display: flex !important;
|
|
164
|
+
flex-wrap: wrap !important;
|
|
165
|
+
gap: 0.5rem !important;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.pickupDialogRoot .selectorWrapper [class*="filterPillsScroll"] button {
|
|
169
|
+
padding: 0.875rem 1.25rem !important;
|
|
170
|
+
font-family: 'Figtree', sans-serif !important;
|
|
171
|
+
font-size: 0.875rem !important;
|
|
172
|
+
font-weight: 500 !important;
|
|
173
|
+
border-radius: 9999px !important;
|
|
174
|
+
transition: background-color 0.15s, color 0.15s !important;
|
|
175
|
+
flex-shrink: 0 !important;
|
|
176
|
+
border: none !important;
|
|
177
|
+
cursor: pointer !important;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* Unselected - stone/grey to match booking flow */
|
|
181
|
+
.pickupDialogRoot .selectorWrapper [class*="filterPillsScroll"] button:not([class*="bg-emerald"]) {
|
|
182
|
+
background-color: #f5f5f4 !important;
|
|
183
|
+
color: #44403c !important;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.pickupDialogRoot .selectorWrapper [class*="filterPillsScroll"] button:hover:not([class*="bg-emerald"]) {
|
|
187
|
+
background-color: #e7e5e4 !important;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* Selected - emerald green to match booking flow */
|
|
191
|
+
.pickupDialogRoot .selectorWrapper [class*="filterPillsScroll"] button[class*="bg-emerald"] {
|
|
192
|
+
background-color: #059669 !important;
|
|
193
|
+
color: #fff !important;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.pickupDialogRoot .selectorWrapper [class*="filterPillsScroll"] button[class*="bg-emerald"]:hover {
|
|
197
|
+
background-color: #047857 !important;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.pickupDialogRoot .mapContainer {
|
|
201
|
+
margin-bottom: var(--spacing-medium);
|
|
202
|
+
border-radius: 12px;
|
|
203
|
+
overflow: hidden;
|
|
204
|
+
border: 1px solid var(--accent-orange-10, rgba(0, 0, 0, 0.08));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.map {
|
|
208
|
+
width: 100%;
|
|
209
|
+
height: 320px;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.mapFallback {
|
|
213
|
+
font-size: 0.875rem;
|
|
214
|
+
color: var(--grey-text);
|
|
215
|
+
margin-bottom: var(--spacing-medium);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.infoWindow {
|
|
219
|
+
padding: 0.25rem;
|
|
220
|
+
min-width: 200px;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.infoTitle {
|
|
224
|
+
font-weight: 600;
|
|
225
|
+
margin: 0 0 0.25rem 0;
|
|
226
|
+
font-size: 1rem;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.infoAddress {
|
|
230
|
+
font-size: 0.875rem;
|
|
231
|
+
color: var(--grey-text);
|
|
232
|
+
margin: 0 0 0.5rem 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.selectBtn {
|
|
236
|
+
width: 100%;
|
|
237
|
+
padding: 0.5rem 0.75rem;
|
|
238
|
+
background: var(--accent-orange, #e85a2e);
|
|
239
|
+
color: white;
|
|
240
|
+
border: none;
|
|
241
|
+
border-radius: 8px;
|
|
242
|
+
font-weight: 600;
|
|
243
|
+
font-size: 0.875rem;
|
|
244
|
+
cursor: pointer;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.selectBtn:hover:not(:disabled) {
|
|
248
|
+
background: #e85a2e;
|
|
249
|
+
opacity: 0.9;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.selectBtn:disabled {
|
|
253
|
+
opacity: 0.7;
|
|
254
|
+
cursor: not-allowed;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.list {
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
gap: 0.5rem;
|
|
261
|
+
margin-bottom: var(--spacing-large);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.listItem {
|
|
265
|
+
display: flex;
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
align-items: flex-start;
|
|
268
|
+
padding: 1rem;
|
|
269
|
+
border: 2px solid var(--accent-orange-10, rgba(0, 0, 0, 0.08));
|
|
270
|
+
border-radius: 12px;
|
|
271
|
+
background: transparent;
|
|
272
|
+
cursor: pointer;
|
|
273
|
+
text-align: left;
|
|
274
|
+
transition: border-color 0.15s, background 0.15s;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.listItem:hover {
|
|
278
|
+
border-color: var(--accent-orange, #e85a2e);
|
|
279
|
+
background: rgba(232, 90, 46, 0.05);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.listItemSelected {
|
|
283
|
+
border-color: var(--accent-orange, #e85a2e);
|
|
284
|
+
background: rgba(232, 90, 46, 0.08);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.listName {
|
|
288
|
+
font-weight: 600;
|
|
289
|
+
color: var(--primary-text, #1f2937);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.listAddress {
|
|
293
|
+
font-size: 0.875rem;
|
|
294
|
+
color: var(--grey-text);
|
|
295
|
+
margin-top: 0.25rem;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.pickupDialogRoot .footer {
|
|
299
|
+
display: flex;
|
|
300
|
+
justify-content: flex-end;
|
|
301
|
+
gap: 0.75rem;
|
|
302
|
+
padding-top: var(--spacing-medium);
|
|
303
|
+
border-top: 1px solid #e5e7eb !important;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.pickupDialogRoot .footerBtn {
|
|
307
|
+
font-family: 'Figtree', sans-serif;
|
|
308
|
+
font-size: 0.875rem;
|
|
309
|
+
font-weight: 600;
|
|
310
|
+
padding: 0.625rem 1.25rem;
|
|
311
|
+
border-radius: 9999px;
|
|
312
|
+
cursor: pointer;
|
|
313
|
+
transition: opacity 0.15s, filter 0.15s;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.pickupDialogRoot .footerBtnOutline {
|
|
317
|
+
border: 2px solid var(--grey-text, #6b7280);
|
|
318
|
+
color: var(--grey-text, #6b7280);
|
|
319
|
+
background: transparent;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.pickupDialogRoot .footerBtnOutline:hover {
|
|
323
|
+
opacity: 0.88;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.pickupDialogRoot .footerBtnPrimary {
|
|
327
|
+
border: 2px solid var(--accent-orange, #e85a2e);
|
|
328
|
+
background: var(--accent-orange, #e85a2e);
|
|
329
|
+
color: #fff;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.pickupDialogRoot .footerBtnPrimary:hover:not(:disabled) {
|
|
333
|
+
filter: brightness(1.05);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.pickupDialogRoot .footerBtnPrimary:disabled {
|
|
337
|
+
opacity: 0.45;
|
|
338
|
+
cursor: not-allowed;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* Itinerary preview before save */
|
|
342
|
+
.pickupDialogRoot .itineraryPreview {
|
|
343
|
+
margin-top: 1rem;
|
|
344
|
+
padding: 1rem;
|
|
345
|
+
background: var(--accent-orange-10, rgba(0, 0, 0, 0.04));
|
|
346
|
+
border-radius: 12px;
|
|
347
|
+
border: 1px solid var(--accent-orange-10, rgba(0, 0, 0, 0.08));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.pickupDialogRoot .previewTitle {
|
|
351
|
+
font-size: 0.875rem;
|
|
352
|
+
font-weight: 600;
|
|
353
|
+
margin: 0 0 0.5rem 0;
|
|
354
|
+
color: var(--primary-text, #1f2937);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.pickupDialogRoot .previewIntro {
|
|
358
|
+
font-size: 0.8125rem;
|
|
359
|
+
color: var(--grey-text, #6b7280);
|
|
360
|
+
margin: 0 0 0.75rem 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.pickupDialogRoot .previewList {
|
|
364
|
+
margin: 0;
|
|
365
|
+
padding-left: 1.25rem;
|
|
366
|
+
font-size: 0.8125rem;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.pickupDialogRoot .previewItem {
|
|
370
|
+
margin-bottom: 0.5rem;
|
|
371
|
+
display: flex;
|
|
372
|
+
flex-wrap: wrap;
|
|
373
|
+
align-items: baseline;
|
|
374
|
+
gap: 0.5rem;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.pickupDialogRoot .previewOld {
|
|
378
|
+
color: var(--grey-text, #6b7280);
|
|
379
|
+
text-decoration: line-through;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.pickupDialogRoot .previewArrow {
|
|
383
|
+
color: var(--accent-orange, #e85a2e);
|
|
384
|
+
font-weight: 600;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.pickupDialogRoot .previewNew {
|
|
388
|
+
color: var(--primary-text, #1f2937);
|
|
389
|
+
font-weight: 500;
|
|
390
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
4
|
+
import { formatBookingRefForDisplay } from '../../lib/format-booking-ref';
|
|
5
|
+
import {
|
|
6
|
+
fetchProducts,
|
|
7
|
+
updatePickupLocation,
|
|
8
|
+
previewPickupLocationChange,
|
|
9
|
+
type Product,
|
|
10
|
+
} from '../../lib/booking-api';
|
|
11
|
+
import { PickupLocationSelector } from './PickupLocationSelector';
|
|
12
|
+
import { useTranslations } from '../../lib/booking/i18n';
|
|
13
|
+
import styles from './PickupLocationDialog.module.css';
|
|
14
|
+
|
|
15
|
+
function findProductWithPickupLocations(
|
|
16
|
+
products: Product[],
|
|
17
|
+
bookingProductId: string
|
|
18
|
+
): Product | null {
|
|
19
|
+
for (const p of products) {
|
|
20
|
+
if (p.productId === bookingProductId) return p;
|
|
21
|
+
if (p.pickupLocations?.some((loc) => loc.id === bookingProductId)) return p;
|
|
22
|
+
const opt = (p as Product & { options?: { optionId: string }[] })?.options?.find(
|
|
23
|
+
(o) => o.optionId === bookingProductId
|
|
24
|
+
);
|
|
25
|
+
if (opt) return p;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Itinerary rows shown on manage-booking (may include optional label/time from API). */
|
|
31
|
+
export interface PickupDialogItineraryStep {
|
|
32
|
+
label?: string | null;
|
|
33
|
+
time?: string | null;
|
|
34
|
+
stepType?: string | null;
|
|
35
|
+
place?: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Minimal booking shape required by the manage-booking pickup dialog. */
|
|
39
|
+
export interface PickupLocationDialogBooking {
|
|
40
|
+
bookingReference: string;
|
|
41
|
+
companyId?: string | null;
|
|
42
|
+
productId: string;
|
|
43
|
+
pickupLocationId?: string | null;
|
|
44
|
+
travelerHotel?: string | null;
|
|
45
|
+
customer?: { lastName?: string | null } | null;
|
|
46
|
+
productType?: string | null;
|
|
47
|
+
itineraryDisplay?: PickupDialogItineraryStep[] | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PickupLocationDialogProps {
|
|
51
|
+
isOpen: boolean;
|
|
52
|
+
onClose: () => void;
|
|
53
|
+
booking: PickupLocationDialogBooking;
|
|
54
|
+
/** Prefer URL query `ref` / `bookingReference` when present (e.g. manage-booking page). */
|
|
55
|
+
bookingReferenceFromUrl?: string | null;
|
|
56
|
+
onSuccess: (updatedBooking: unknown) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getPickupTimeFromItinerary(itinerary: PickupDialogItineraryStep[] | null | undefined): string {
|
|
60
|
+
if (!itinerary?.length) return '—';
|
|
61
|
+
const pickupStep = itinerary.find((s) => s.stepType === 'pickup');
|
|
62
|
+
return pickupStep?.time?.trim() || 'TBD';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getDropoffTimeFromItinerary(itinerary: PickupDialogItineraryStep[] | null | undefined): string {
|
|
66
|
+
if (!itinerary?.length) return '—';
|
|
67
|
+
const dropOffStep = [...itinerary].reverse().find((s) => s.stepType === 'drop_off');
|
|
68
|
+
return dropOffStep?.time?.trim() || 'TBD';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getItineraryStepLabel(step: PickupDialogItineraryStep, includeTime = false): string {
|
|
72
|
+
if (step.label?.trim()) {
|
|
73
|
+
const base = step.label.trim();
|
|
74
|
+
return includeTime && step.time?.trim() ? `${base} ${step.time.trim()}` : base;
|
|
75
|
+
}
|
|
76
|
+
const place = step.place?.trim();
|
|
77
|
+
const placeDisplay =
|
|
78
|
+
place === 'your_pickup_location'
|
|
79
|
+
? 'your pickup location'
|
|
80
|
+
: place === 'the_destination'
|
|
81
|
+
? 'the destination'
|
|
82
|
+
: place ?? '';
|
|
83
|
+
const timePart = includeTime && step.time?.trim() ? ` ${step.time.trim()}` : '';
|
|
84
|
+
switch (step.stepType) {
|
|
85
|
+
case 'pickup':
|
|
86
|
+
return placeDisplay ? `Pickup at ${placeDisplay}${timePart}` : `Pickup${timePart}`;
|
|
87
|
+
case 'drop_off':
|
|
88
|
+
return placeDisplay ? `Drop off at ${placeDisplay}${timePart}` : `Drop-off${timePart}`;
|
|
89
|
+
case 'arrive':
|
|
90
|
+
return placeDisplay ? `Arrive at ${placeDisplay}${timePart}` : `Arrive${timePart}`;
|
|
91
|
+
case 'depart':
|
|
92
|
+
return placeDisplay ? `Depart ${placeDisplay}${timePart}` : `Depart${timePart}`;
|
|
93
|
+
case 'trip_end':
|
|
94
|
+
return 'Trip ends';
|
|
95
|
+
case 'draft':
|
|
96
|
+
return placeDisplay || 'Stop';
|
|
97
|
+
default:
|
|
98
|
+
return placeDisplay || (step.stepType ?? 'Step');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function PickupLocationDialog({
|
|
103
|
+
isOpen,
|
|
104
|
+
onClose,
|
|
105
|
+
booking,
|
|
106
|
+
bookingReferenceFromUrl = '',
|
|
107
|
+
onSuccess,
|
|
108
|
+
}: PickupLocationDialogProps) {
|
|
109
|
+
const { t } = useTranslations();
|
|
110
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
111
|
+
const [loading, setLoading] = useState(false);
|
|
112
|
+
const [error, setError] = useState<string | null>(null);
|
|
113
|
+
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(
|
|
114
|
+
booking.pickupLocationId ?? null
|
|
115
|
+
);
|
|
116
|
+
const [selectedLocationName, setSelectedLocationName] = useState<string | null>(null);
|
|
117
|
+
const [selectedCustomAddress, setSelectedCustomAddress] = useState<string | null>(
|
|
118
|
+
booking.travelerHotel ?? null
|
|
119
|
+
);
|
|
120
|
+
const [saving, setSaving] = useState(false);
|
|
121
|
+
const [previewItinerary, setPreviewItinerary] = useState<PickupDialogItineraryStep[] | null>(null);
|
|
122
|
+
|
|
123
|
+
const refFromUrl = (bookingReferenceFromUrl ?? '').trim();
|
|
124
|
+
const refFromBooking =
|
|
125
|
+
formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference || '';
|
|
126
|
+
const effectiveBookingRef = (refFromUrl || refFromBooking).trim();
|
|
127
|
+
|
|
128
|
+
const companyId = booking.companyId ?? '';
|
|
129
|
+
const lastName = booking.customer?.lastName ?? '';
|
|
130
|
+
const isPrivateShuttle = booking.productType === 'PRIVATE_SHUTTLE';
|
|
131
|
+
|
|
132
|
+
const product = useMemo(
|
|
133
|
+
() => findProductWithPickupLocations(products, booking.productId),
|
|
134
|
+
[products, booking.productId]
|
|
135
|
+
);
|
|
136
|
+
const pickupLocations = product?.pickupLocations ?? [];
|
|
137
|
+
const destinations = product?.destinations ?? [];
|
|
138
|
+
|
|
139
|
+
const loadProducts = useCallback(async () => {
|
|
140
|
+
if (!companyId?.trim()) {
|
|
141
|
+
setError('Missing company information');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
setLoading(true);
|
|
145
|
+
setError(null);
|
|
146
|
+
try {
|
|
147
|
+
const data = await fetchProducts(companyId);
|
|
148
|
+
setProducts(data);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
setError(e instanceof Error ? e.message : 'Failed to load pickup locations');
|
|
151
|
+
} finally {
|
|
152
|
+
setLoading(false);
|
|
153
|
+
}
|
|
154
|
+
}, [companyId]);
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (isOpen && companyId) loadProducts();
|
|
158
|
+
}, [isOpen, companyId, loadProducts]);
|
|
159
|
+
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (isOpen) {
|
|
162
|
+
setSelectedLocationId(booking.pickupLocationId ?? null);
|
|
163
|
+
setSelectedLocationName(null);
|
|
164
|
+
setSelectedCustomAddress(booking.travelerHotel ?? null);
|
|
165
|
+
setPreviewItinerary(null);
|
|
166
|
+
}
|
|
167
|
+
}, [isOpen, booking.pickupLocationId, booking.travelerHotel]);
|
|
168
|
+
|
|
169
|
+
const selectedPickupName = useMemo(() => {
|
|
170
|
+
if (selectedCustomAddress) return selectedCustomAddress;
|
|
171
|
+
if (selectedLocationId) {
|
|
172
|
+
return (
|
|
173
|
+
selectedLocationName ??
|
|
174
|
+
pickupLocations.find((l) => l.id === selectedLocationId)?.name ??
|
|
175
|
+
selectedLocationId
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}, [selectedLocationId, selectedLocationName, selectedCustomAddress, pickupLocations]);
|
|
180
|
+
|
|
181
|
+
const hasValidSelection = Boolean(selectedLocationId || selectedCustomAddress);
|
|
182
|
+
const hasChanged =
|
|
183
|
+
selectedLocationId !== (booking.pickupLocationId ?? null) ||
|
|
184
|
+
selectedCustomAddress !== (booking.travelerHotel ?? null);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!isOpen || !hasValidSelection || !hasChanged || !lastName?.trim()) {
|
|
188
|
+
setPreviewItinerary(null);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let cancelled = false;
|
|
192
|
+
const payload = selectedCustomAddress
|
|
193
|
+
? { travelerHotel: selectedCustomAddress }
|
|
194
|
+
: selectedLocationId
|
|
195
|
+
? { pickupLocationId: selectedLocationId }
|
|
196
|
+
: null;
|
|
197
|
+
if (!payload) return;
|
|
198
|
+
previewPickupLocationChange(effectiveBookingRef, lastName, payload)
|
|
199
|
+
.then((data) => {
|
|
200
|
+
if (!cancelled && data?.itineraryDisplay) {
|
|
201
|
+
setPreviewItinerary(data.itineraryDisplay as PickupDialogItineraryStep[]);
|
|
202
|
+
} else {
|
|
203
|
+
setPreviewItinerary(null);
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
.catch(() => setPreviewItinerary(null));
|
|
207
|
+
return () => {
|
|
208
|
+
cancelled = true;
|
|
209
|
+
};
|
|
210
|
+
}, [
|
|
211
|
+
isOpen,
|
|
212
|
+
hasValidSelection,
|
|
213
|
+
hasChanged,
|
|
214
|
+
selectedLocationId,
|
|
215
|
+
selectedCustomAddress,
|
|
216
|
+
effectiveBookingRef,
|
|
217
|
+
lastName,
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
const currentPickupName =
|
|
221
|
+
booking.travelerHotel ||
|
|
222
|
+
(booking.pickupLocationId
|
|
223
|
+
? pickupLocations.find((l) => l.id === booking.pickupLocationId)?.name ?? booking.pickupLocationId
|
|
224
|
+
: null);
|
|
225
|
+
|
|
226
|
+
const itineraryDisplay = booking.itineraryDisplay ?? [];
|
|
227
|
+
const pickupOrDropOffSteps = itineraryDisplay.filter(
|
|
228
|
+
(s) => s.stepType === 'pickup' || s.stepType === 'drop_off'
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const handleSave = async () => {
|
|
232
|
+
if (!lastName?.trim()) {
|
|
233
|
+
setError('Last name is required to update pickup');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!hasValidSelection) {
|
|
237
|
+
setError('Please select a pickup location');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setSaving(true);
|
|
242
|
+
setError(null);
|
|
243
|
+
try {
|
|
244
|
+
let payload: { pickupLocationId?: string; travelerHotel?: string };
|
|
245
|
+
if (selectedCustomAddress) {
|
|
246
|
+
payload = { travelerHotel: selectedCustomAddress };
|
|
247
|
+
} else if (selectedLocationId) {
|
|
248
|
+
payload = { pickupLocationId: selectedLocationId, travelerHotel: selectedLocationName ?? undefined };
|
|
249
|
+
} else {
|
|
250
|
+
setError('Please select a pickup location');
|
|
251
|
+
setSaving(false);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const updated = await updatePickupLocation(effectiveBookingRef, lastName, payload);
|
|
256
|
+
onSuccess(updated);
|
|
257
|
+
onClose();
|
|
258
|
+
} catch (e) {
|
|
259
|
+
setError(e instanceof Error ? e.message : 'Failed to update pickup location');
|
|
260
|
+
} finally {
|
|
261
|
+
setSaving(false);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (!isOpen) return null;
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div
|
|
269
|
+
className={styles.overlay}
|
|
270
|
+
onClick={onClose}
|
|
271
|
+
role="dialog"
|
|
272
|
+
aria-modal="true"
|
|
273
|
+
aria-labelledby="pickup-dialog-title"
|
|
274
|
+
>
|
|
275
|
+
<div className={`${styles.modal} ${styles.pickupDialogRoot}`} onClick={(e) => e.stopPropagation()}>
|
|
276
|
+
<div
|
|
277
|
+
className={styles.header}
|
|
278
|
+
style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', alignItems: 'center' }}
|
|
279
|
+
>
|
|
280
|
+
<span className={styles.headerSpacer} aria-hidden />
|
|
281
|
+
<h2
|
|
282
|
+
id="pickup-dialog-title"
|
|
283
|
+
className={styles.title}
|
|
284
|
+
style={{ textAlign: 'center', justifySelf: 'center' }}
|
|
285
|
+
>
|
|
286
|
+
{t('pickup.title') || 'Select pickup location'}
|
|
287
|
+
</h2>
|
|
288
|
+
<button type="button" onClick={onClose} className={styles.closeBtn} aria-label="Close">
|
|
289
|
+
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
290
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
291
|
+
</svg>
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<div className={styles.content}>
|
|
296
|
+
{loading ? (
|
|
297
|
+
<div className={styles.loading}>Loading pickup locations…</div>
|
|
298
|
+
) : error && !pickupLocations.length ? (
|
|
299
|
+
<div className={styles.error}>{error}</div>
|
|
300
|
+
) : pickupLocations.length === 0 ? (
|
|
301
|
+
<div className={styles.empty}>No pickup locations available for this tour.</div>
|
|
302
|
+
) : (
|
|
303
|
+
<>
|
|
304
|
+
<div className={styles.currentPickupSection}>
|
|
305
|
+
<p className={styles.currentPickupLabel}>Current pickup</p>
|
|
306
|
+
<p className={styles.currentPickupName}>
|
|
307
|
+
{currentPickupName ?? (t('booking.pickupLocationUnknown') || "I don't know")}
|
|
308
|
+
</p>
|
|
309
|
+
{(booking.pickupLocationId || booking.travelerHotel) && (
|
|
310
|
+
<p className={styles.currentPickupTimes}>
|
|
311
|
+
Pickup: {getPickupTimeFromItinerary(itineraryDisplay)} · Drop-off:{' '}
|
|
312
|
+
{getDropoffTimeFromItinerary(itineraryDisplay)}
|
|
313
|
+
</p>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
<div
|
|
317
|
+
className={`${styles.selectorWrapper} ${styles.selectorWrapperConstrained}`}
|
|
318
|
+
style={{ fontFamily: "'Figtree', sans-serif" }}
|
|
319
|
+
>
|
|
320
|
+
<PickupLocationSelector
|
|
321
|
+
hideTitle
|
|
322
|
+
hideSkipOption
|
|
323
|
+
pickupLocations={pickupLocations}
|
|
324
|
+
selectedLocationId={selectedLocationId}
|
|
325
|
+
selectedCustomAddress={selectedCustomAddress}
|
|
326
|
+
allowCustomLocation={isPrivateShuttle}
|
|
327
|
+
restrictCustomLocationToServiceArea={isPrivateShuttle}
|
|
328
|
+
destinations={destinations}
|
|
329
|
+
onLocationSelect={(locationId, customLocation, locationName) => {
|
|
330
|
+
setError('');
|
|
331
|
+
if (customLocation) {
|
|
332
|
+
setSelectedLocationId(null);
|
|
333
|
+
setSelectedLocationName(null);
|
|
334
|
+
setSelectedCustomAddress(customLocation.address);
|
|
335
|
+
} else {
|
|
336
|
+
setSelectedLocationId(locationId);
|
|
337
|
+
setSelectedLocationName(locationName ?? null);
|
|
338
|
+
setSelectedCustomAddress(null);
|
|
339
|
+
}
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
{hasValidSelection && hasChanged && pickupOrDropOffSteps.length > 0 && (
|
|
345
|
+
<div className={styles.itineraryPreview}>
|
|
346
|
+
<h3 className={styles.previewTitle}>Itinerary changes</h3>
|
|
347
|
+
<p className={styles.previewIntro}>
|
|
348
|
+
Your itinerary will be updated with the new pickup location:
|
|
349
|
+
</p>
|
|
350
|
+
<ul className={styles.previewList}>
|
|
351
|
+
{pickupOrDropOffSteps.map((step, i) => {
|
|
352
|
+
const previewStep = previewItinerary?.find((s) => s.stepType === step.stepType);
|
|
353
|
+
const newTime = previewStep?.time?.trim();
|
|
354
|
+
const newLabel =
|
|
355
|
+
step.stepType === 'pickup'
|
|
356
|
+
? selectedPickupName
|
|
357
|
+
? `Pickup at ${selectedPickupName}${newTime ? ` ${newTime}` : ''}`
|
|
358
|
+
: t('booking.pickupLocationUnknown') || "I don't know"
|
|
359
|
+
: step.stepType === 'drop_off'
|
|
360
|
+
? selectedPickupName
|
|
361
|
+
? `Drop off at ${selectedPickupName}${newTime ? ` ${newTime}` : ''}`
|
|
362
|
+
: t('booking.pickupLocationUnknown') || "I don't know"
|
|
363
|
+
: getItineraryStepLabel(previewStep ?? step, true);
|
|
364
|
+
return (
|
|
365
|
+
<li key={i} className={styles.previewItem}>
|
|
366
|
+
<span className={styles.previewOld}>{getItineraryStepLabel(step, true)}</span>
|
|
367
|
+
<span className={styles.previewArrow}>→</span>
|
|
368
|
+
<span className={styles.previewNew}>{newLabel}</span>
|
|
369
|
+
</li>
|
|
370
|
+
);
|
|
371
|
+
})}
|
|
372
|
+
</ul>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
{error && <div className={styles.error}>{error}</div>}
|
|
377
|
+
|
|
378
|
+
<div className={styles.footer}>
|
|
379
|
+
<button type="button" className={`${styles.footerBtn} ${styles.footerBtnOutline}`} onClick={onClose}>
|
|
380
|
+
Cancel
|
|
381
|
+
</button>
|
|
382
|
+
<button
|
|
383
|
+
type="button"
|
|
384
|
+
className={`${styles.footerBtn} ${styles.footerBtnPrimary}`}
|
|
385
|
+
onClick={handleSave}
|
|
386
|
+
disabled={!hasValidSelection || !hasChanged || saving}
|
|
387
|
+
>
|
|
388
|
+
{saving ? 'Saving…' : 'Save pickup location'}
|
|
389
|
+
</button>
|
|
390
|
+
</div>
|
|
391
|
+
</>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
);
|
|
397
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,13 @@ export type {
|
|
|
44
44
|
export { getItineraryStepLabel } from './lib/booking/itinerary-display';
|
|
45
45
|
export { PARTNER_EMBEDDED_BOOKING_FLOW_UI_BASE } from './components/booking/booking-flow-ui';
|
|
46
46
|
export { default as BookingDialog } from './components/booking/BookingDialog';
|
|
47
|
+
export { PickupLocationDialog } from './components/booking/PickupLocationDialog';
|
|
48
|
+
export type {
|
|
49
|
+
PickupLocationDialogBooking,
|
|
50
|
+
PickupLocationDialogProps,
|
|
51
|
+
PickupDialogItineraryStep,
|
|
52
|
+
} from './components/booking/PickupLocationDialog';
|
|
53
|
+
export { formatBookingRefForDisplay } from './lib/format-booking-ref';
|
|
47
54
|
export { default as BookingProductGrid } from './components/booking/BookingProductGrid';
|
|
48
55
|
export { DefaultTermsContent as TermsContent } from './components/booking/DefaultTermsContent';
|
|
49
56
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format booking reference for display and URLs.
|
|
3
|
+
* Strips the bookRef_ prefix so users see the short form. Backend accepts both forms.
|
|
4
|
+
*/
|
|
5
|
+
export function formatBookingRefForDisplay(ref: string | null | undefined): string {
|
|
6
|
+
if (!ref || typeof ref !== 'string') return '';
|
|
7
|
+
const trimmed = ref.trim();
|
|
8
|
+
if (trimmed.toLowerCase().startsWith('bookref_')) {
|
|
9
|
+
return trimmed.slice(8);
|
|
10
|
+
}
|
|
11
|
+
return trimmed;
|
|
12
|
+
}
|