@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.71",
3
+ "version": "1.2.74",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -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: same as normal — discount from get-promo-discount on the new selection (promo code from 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: same as normal discount from get-promo-discount on the new selection (promo code from 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
 
@@ -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
+ }