@ticketboothapp/booking 1.2.71 → 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 +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +16 -2
- package/src/components/booking/ChangeBookingFlow.tsx +17 -2
- package/src/components/booking/PickupLocationDialog.module.css +390 -0
- package/src/components/booking/PickupLocationDialog.tsx +397 -0
- package/src/index.ts +7 -0
- package/src/lib/booking-api.ts +18 -1
- package/src/lib/format-booking-ref.ts +12 -0
package/package.json
CHANGED
|
@@ -3105,6 +3105,7 @@ export function AdminChangeBookingFlow({
|
|
|
3105
3105
|
ticketLineItems: [] as Array<{ category: string; qty: number }>,
|
|
3106
3106
|
effectiveSubtotal: 0,
|
|
3107
3107
|
appliedPromoCode: null as string | null,
|
|
3108
|
+
changeBookingPromo: null as { priorAmount?: number } | null,
|
|
3108
3109
|
});
|
|
3109
3110
|
|
|
3110
3111
|
const promoDiscountFetchKey = useMemo(() => {
|
|
@@ -3123,6 +3124,7 @@ export function AdminChangeBookingFlow({
|
|
|
3123
3124
|
currency,
|
|
3124
3125
|
quantitiesSignature,
|
|
3125
3126
|
String(Math.round(effectiveSubtotal * 100)),
|
|
3127
|
+
lockedPromoCode ? String(Math.round((originalReceipt?.promoAmount ?? 0) * 100)) : '',
|
|
3126
3128
|
].join('::');
|
|
3127
3129
|
}, [
|
|
3128
3130
|
appliedPromoCode,
|
|
@@ -3135,6 +3137,8 @@ export function AdminChangeBookingFlow({
|
|
|
3135
3137
|
currency,
|
|
3136
3138
|
quantitiesSignature,
|
|
3137
3139
|
effectiveSubtotal,
|
|
3140
|
+
lockedPromoCode,
|
|
3141
|
+
originalReceipt?.promoAmount,
|
|
3138
3142
|
]);
|
|
3139
3143
|
|
|
3140
3144
|
promoDiscountParamsRef.current = {
|
|
@@ -3142,6 +3146,9 @@ export function AdminChangeBookingFlow({
|
|
|
3142
3146
|
ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty })),
|
|
3143
3147
|
effectiveSubtotal,
|
|
3144
3148
|
appliedPromoCode,
|
|
3149
|
+
changeBookingPromo: lockedPromoCode
|
|
3150
|
+
? { priorAmount: originalReceipt?.promoAmount }
|
|
3151
|
+
: null,
|
|
3145
3152
|
};
|
|
3146
3153
|
|
|
3147
3154
|
useEffect(() => {
|
|
@@ -3161,6 +3168,7 @@ export function AdminChangeBookingFlow({
|
|
|
3161
3168
|
ticketLineItems: lines,
|
|
3162
3169
|
effectiveSubtotal: sub,
|
|
3163
3170
|
appliedPromoCode: code,
|
|
3171
|
+
changeBookingPromo,
|
|
3164
3172
|
} = promoDiscountParamsRef.current;
|
|
3165
3173
|
if (!code || !sel) return;
|
|
3166
3174
|
const companyId = product.companyId ?? env.COMPANY_ID;
|
|
@@ -3178,7 +3186,13 @@ export function AdminChangeBookingFlow({
|
|
|
3178
3186
|
currency,
|
|
3179
3187
|
items,
|
|
3180
3188
|
sel.dateTime,
|
|
3181
|
-
sub
|
|
3189
|
+
sub,
|
|
3190
|
+
changeBookingPromo
|
|
3191
|
+
? {
|
|
3192
|
+
forBookingChange: true,
|
|
3193
|
+
priorPromoDiscountAmount: changeBookingPromo.priorAmount,
|
|
3194
|
+
}
|
|
3195
|
+
: undefined
|
|
3182
3196
|
)
|
|
3183
3197
|
.then((res) => {
|
|
3184
3198
|
if (cancelled) return;
|
|
@@ -3204,7 +3218,7 @@ export function AdminChangeBookingFlow({
|
|
|
3204
3218
|
|
|
3205
3219
|
// Percentage/fixed promos: tax on discounted amount (promo before GST per CRA guidance).
|
|
3206
3220
|
// Vouchers and gift cards: tax on full subtotal (voucher discount includes tax on free portion; gift card is payment).
|
|
3207
|
-
// Change booking:
|
|
3221
|
+
// Change booking: get-promo-discount uses forBookingChange + prior promo from receipt so expired codes stay locked (BE).
|
|
3208
3222
|
const lockedPromoFallbackAmount =
|
|
3209
3223
|
lockedPromoCode
|
|
3210
3224
|
? Math.max(0, originalReceipt?.promoAmount ?? 0)
|
|
@@ -2995,6 +2995,8 @@ export function ChangeBookingFlow({
|
|
|
2995
2995
|
ticketLineItems: [] as Array<{ category: string; qty: number }>,
|
|
2996
2996
|
effectiveSubtotal: 0,
|
|
2997
2997
|
appliedPromoCode: null as string | null,
|
|
2998
|
+
/** Change flow: pass BE stored promo discount for expired / fixed promo locking. */
|
|
2999
|
+
changeBookingPromo: null as { priorAmount?: number } | null,
|
|
2998
3000
|
});
|
|
2999
3001
|
|
|
3000
3002
|
const promoDiscountFetchKey = useMemo(() => {
|
|
@@ -3013,6 +3015,7 @@ export function ChangeBookingFlow({
|
|
|
3013
3015
|
currency,
|
|
3014
3016
|
quantitiesSignature,
|
|
3015
3017
|
String(Math.round(effectiveSubtotal * 100)),
|
|
3018
|
+
lockedPromoCode ? String(Math.round((originalReceipt?.promoAmount ?? 0) * 100)) : '',
|
|
3016
3019
|
].join('::');
|
|
3017
3020
|
}, [
|
|
3018
3021
|
appliedPromoCode,
|
|
@@ -3025,6 +3028,8 @@ export function ChangeBookingFlow({
|
|
|
3025
3028
|
currency,
|
|
3026
3029
|
quantitiesSignature,
|
|
3027
3030
|
effectiveSubtotal,
|
|
3031
|
+
lockedPromoCode,
|
|
3032
|
+
originalReceipt?.promoAmount,
|
|
3028
3033
|
]);
|
|
3029
3034
|
|
|
3030
3035
|
promoDiscountParamsRef.current = {
|
|
@@ -3032,6 +3037,9 @@ export function ChangeBookingFlow({
|
|
|
3032
3037
|
ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty })),
|
|
3033
3038
|
effectiveSubtotal,
|
|
3034
3039
|
appliedPromoCode,
|
|
3040
|
+
changeBookingPromo: lockedPromoCode
|
|
3041
|
+
? { priorAmount: originalReceipt?.promoAmount }
|
|
3042
|
+
: null,
|
|
3035
3043
|
};
|
|
3036
3044
|
|
|
3037
3045
|
useEffect(() => {
|
|
@@ -3051,6 +3059,7 @@ export function ChangeBookingFlow({
|
|
|
3051
3059
|
ticketLineItems: lines,
|
|
3052
3060
|
effectiveSubtotal: sub,
|
|
3053
3061
|
appliedPromoCode: code,
|
|
3062
|
+
changeBookingPromo,
|
|
3054
3063
|
} = promoDiscountParamsRef.current;
|
|
3055
3064
|
if (!code || !sel) return;
|
|
3056
3065
|
const companyId = product.companyId ?? env.COMPANY_ID;
|
|
@@ -3068,7 +3077,13 @@ export function ChangeBookingFlow({
|
|
|
3068
3077
|
currency,
|
|
3069
3078
|
items,
|
|
3070
3079
|
sel.dateTime,
|
|
3071
|
-
sub
|
|
3080
|
+
sub,
|
|
3081
|
+
changeBookingPromo
|
|
3082
|
+
? {
|
|
3083
|
+
forBookingChange: true,
|
|
3084
|
+
priorPromoDiscountAmount: changeBookingPromo.priorAmount,
|
|
3085
|
+
}
|
|
3086
|
+
: undefined
|
|
3072
3087
|
)
|
|
3073
3088
|
.then((res) => {
|
|
3074
3089
|
if (cancelled) return;
|
|
@@ -3094,7 +3109,7 @@ export function ChangeBookingFlow({
|
|
|
3094
3109
|
|
|
3095
3110
|
// Percentage/fixed promos: tax on discounted amount (promo before GST per CRA guidance).
|
|
3096
3111
|
// Vouchers and gift cards: tax on full subtotal (voucher discount includes tax on free portion; gift card is payment).
|
|
3097
|
-
// Change booking:
|
|
3112
|
+
// Change booking: get-promo-discount uses forBookingChange + receipt prior promo (BE locks expired % / fixed / voucher).
|
|
3098
3113
|
const lockedPromoFallbackAmount =
|
|
3099
3114
|
lockedPromoCode
|
|
3100
3115
|
? Math.max(0, originalReceipt?.promoAmount ?? 0)
|
|
@@ -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
|
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -651,6 +651,13 @@ export interface GetPromoDiscountResponse {
|
|
|
651
651
|
isVoucher?: boolean;
|
|
652
652
|
}
|
|
653
653
|
|
|
654
|
+
/** Booking change: server locks discount using stored receipt promo when windows expire / fixed codes. */
|
|
655
|
+
export interface GetPromoDiscountBookingChangeOptions {
|
|
656
|
+
forBookingChange?: boolean;
|
|
657
|
+
/** Stored promo discount from the existing booking (major units, same currency as quote). */
|
|
658
|
+
priorPromoDiscountAmount?: number;
|
|
659
|
+
}
|
|
660
|
+
|
|
654
661
|
export async function getPromoDiscount(
|
|
655
662
|
promoCode: string,
|
|
656
663
|
companyId: string,
|
|
@@ -659,7 +666,8 @@ export async function getPromoDiscount(
|
|
|
659
666
|
currency: string,
|
|
660
667
|
items: Array<{ category: string; qty: number }>,
|
|
661
668
|
dateTime?: string,
|
|
662
|
-
subtotal?: number
|
|
669
|
+
subtotal?: number,
|
|
670
|
+
bookingChange?: GetPromoDiscountBookingChangeOptions
|
|
663
671
|
): Promise<GetPromoDiscountResponse> {
|
|
664
672
|
const itemsStr = items.map((i) => `${i.category}:${i.qty}`).join(',');
|
|
665
673
|
const params = new URLSearchParams({
|
|
@@ -672,6 +680,15 @@ export async function getPromoDiscount(
|
|
|
672
680
|
});
|
|
673
681
|
if (dateTime) params.set('dateTime', dateTime);
|
|
674
682
|
if (subtotal != null && subtotal > 0) params.set('subtotal', String(subtotal));
|
|
683
|
+
if (bookingChange?.forBookingChange === true) {
|
|
684
|
+
params.set('forBookingChange', 'true');
|
|
685
|
+
}
|
|
686
|
+
if (
|
|
687
|
+
bookingChange?.priorPromoDiscountAmount != null &&
|
|
688
|
+
Number.isFinite(bookingChange.priorPromoDiscountAmount)
|
|
689
|
+
) {
|
|
690
|
+
params.set('priorPromoDiscountAmount', String(bookingChange.priorPromoDiscountAmount));
|
|
691
|
+
}
|
|
675
692
|
const res = await fetchBookingGetWithRetry(`${API_BASE}/1/get-promo-discount?${params}`);
|
|
676
693
|
if (!res.ok) {
|
|
677
694
|
const err = await res.json();
|
|
@@ -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
|
+
}
|