@ticketboothapp/booking 1.2.25-rc.0 → 1.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/package.json +11 -29
  2. package/src/components/booking/AddOnsSection.tsx +2 -2
  3. package/src/components/booking/AdminPaymentChoiceModal.tsx +1 -1
  4. package/src/components/booking/BookingDialog.tsx +31 -13
  5. package/src/components/booking/BookingFlow.tsx +32 -27
  6. package/src/components/booking/BookingFlowCollage.tsx +10 -6
  7. package/src/components/booking/BookingFlowPlaceholder.tsx +1 -1
  8. package/src/components/booking/BookingFlowPreview.tsx +18 -9
  9. package/src/components/booking/BookingProductGrid.tsx +55 -19
  10. package/src/components/booking/Calendar.module.css +19 -4
  11. package/src/components/booking/Calendar.tsx +13 -8
  12. package/src/components/booking/CancellationPolicySelector.tsx +2 -2
  13. package/src/components/booking/ChangeBookingDialog.tsx +22 -12
  14. package/src/components/booking/CheckoutForm.module.css +10 -0
  15. package/src/components/booking/CheckoutForm.tsx +10 -2
  16. package/src/components/booking/CheckoutModal.tsx +16 -14
  17. package/src/components/booking/DapFlowCollage.tsx +5 -2
  18. package/src/components/booking/DapTourDescription.tsx +4 -4
  19. package/src/components/booking/DependentAddOnBookingDialog.tsx +23 -16
  20. package/src/components/booking/DependentAddOnPaymentForm.tsx +10 -7
  21. package/src/components/booking/ItineraryBox.tsx +6 -6
  22. package/src/components/booking/ItineraryBuilder.tsx +1 -1
  23. package/src/components/booking/MealDrinkAddOnSelector.tsx +3 -3
  24. package/src/components/booking/PickupLocationSelector.tsx +20 -18
  25. package/src/components/booking/PickupTimeSelector.tsx +3 -3
  26. package/src/components/booking/PriceBreakdown.tsx +5 -5
  27. package/src/components/booking/PriceSummary.module.css +7 -0
  28. package/src/components/booking/PriceSummary.tsx +8 -7
  29. package/src/components/booking/PrivateShuttleBookingFlow.tsx +28 -19
  30. package/src/components/booking/PromoCodeInput.module.css +31 -25
  31. package/src/components/booking/PromoCodeInput.tsx +36 -24
  32. package/src/components/booking/ReturnTimeSelector.tsx +3 -3
  33. package/src/components/booking/TermsAcceptance.tsx +7 -2
  34. package/src/components/booking/TicketSelector.tsx +1 -1
  35. package/src/components/booking/TourDescription.tsx +11 -6
  36. package/src/components/booking/booking-flow.css +65 -4
  37. package/src/hooks/useBookingSourceMetadataFromLocation.ts +1 -1
  38. package/src/hooks/useIsBookingLaunchLive.ts +1 -1
  39. package/src/index.ts +26 -64
  40. package/src/providers/booking-dialog-provider.tsx +62 -53
  41. package/src/runtime/BookingHostContext.tsx +39 -0
  42. package/src/runtime/index.ts +13 -0
  43. package/src/runtime/types.ts +86 -0
  44. package/tsconfig.json +3 -5
  45. package/src/assets/icons/minus.svg +0 -7
  46. package/src/assets/icons/partner-logos/getyourguide.svg +0 -8
  47. package/src/assets/icons/plus.svg +0 -3
  48. package/src/colours.css +0 -23
  49. package/src/components/BookingDetails.module.css +0 -1591
  50. package/src/components/BookingDetails.tsx +0 -2264
  51. package/src/components/BookingWidget.tsx +0 -302
  52. package/src/components/ManageBookingView.tsx +0 -437
  53. package/src/components/PhoneInputWithCountry.module.css +0 -131
  54. package/src/components/PhoneInputWithCountry.tsx +0 -44
  55. package/src/components/PickupLocationDialog.module.css +0 -360
  56. package/src/components/PickupLocationDialog.tsx +0 -357
  57. package/src/components/PostBookingDependentAddOnUpsell.module.css +0 -174
  58. package/src/components/PostBookingDependentAddOnUpsell.tsx +0 -407
  59. package/src/components/button.css +0 -245
  60. package/src/components/button.tsx +0 -152
  61. package/src/components/colorable-svg.tsx +0 -29
  62. package/src/components/image.css +0 -29
  63. package/src/components/image.tsx +0 -113
  64. package/src/components/partner/PartnerBookingPage.module.css +0 -130
  65. package/src/components/partner/PartnerBookingPage.tsx +0 -390
  66. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +0 -45
  67. package/src/components/product-tag.module.css +0 -30
  68. package/src/components/product-tag.tsx +0 -34
  69. package/src/components/product-theme-pages/image-modal.tsx +0 -248
  70. package/src/components/product-theme-pages/photo-gallery.module.css +0 -200
  71. package/src/components/terms/TermsContent.tsx +0 -178
  72. package/src/components/value-pill.module.css +0 -59
  73. package/src/components/value-pill.tsx +0 -46
  74. package/src/constants/images.ts +0 -556
  75. package/src/constants/pill-values.ts +0 -210
  76. package/src/constants/products.ts +0 -155
  77. package/src/contexts/AvailabilitiesCacheContext.tsx +0 -125
  78. package/src/contexts/CompanyContext.tsx +0 -70
  79. package/src/data/dap-descriptions/session-couples-families-friends.en.json +0 -61
  80. package/src/data/dap-descriptions/session-elopements.en.json +0 -60
  81. package/src/data/dap-descriptions/session-proposals.en.json +0 -60
  82. package/src/data/product-descriptions/afternoon-delight.en.json +0 -35
  83. package/src/data/product-descriptions/emerald-lake-escape.en.json +0 -68
  84. package/src/data/product-descriptions/lake-louise-adventure.en.json +0 -74
  85. package/src/data/product-descriptions/moraine-lake-adventure.en.json +0 -78
  86. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +0 -65
  87. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +0 -64
  88. package/src/data/product-descriptions/private-tour.en.json +0 -80
  89. package/src/data/product-descriptions/two-lakes-combo.en.json +0 -65
  90. package/src/data/products-config.json +0 -101
  91. package/src/lib/analytics.ts +0 -197
  92. package/src/lib/booking/booking-source.ts +0 -51
  93. package/src/lib/booking/checkout-breakdown.ts +0 -69
  94. package/src/lib/booking/correlation-id.ts +0 -46
  95. package/src/lib/booking/i18n/config.ts +0 -21
  96. package/src/lib/booking/i18n/index.tsx +0 -144
  97. package/src/lib/booking/i18n/messages/en.json +0 -236
  98. package/src/lib/booking/i18n/messages/fr.json +0 -236
  99. package/src/lib/booking/itinerary-display.ts +0 -36
  100. package/src/lib/booking/itinerary-labels.ts +0 -70
  101. package/src/lib/booking/location-calculations.ts +0 -43
  102. package/src/lib/booking/location-utils.ts +0 -165
  103. package/src/lib/booking/map-utils.ts +0 -153
  104. package/src/lib/booking/marker-icons.ts +0 -113
  105. package/src/lib/booking/normalize-booking-product-id.ts +0 -21
  106. package/src/lib/booking/pickup-location-types.ts +0 -25
  107. package/src/lib/booking/places-api.ts +0 -154
  108. package/src/lib/booking/pricing.ts +0 -466
  109. package/src/lib/booking/product-option-id.ts +0 -35
  110. package/src/lib/booking/source-metadata.ts +0 -226
  111. package/src/lib/booking/sunday-week.ts +0 -14
  112. package/src/lib/booking/theme.ts +0 -83
  113. package/src/lib/booking/trace-context.ts +0 -62
  114. package/src/lib/booking/utils.ts +0 -9
  115. package/src/lib/booking-api.ts +0 -1793
  116. package/src/lib/booking-constants.ts +0 -23
  117. package/src/lib/booking-ref.ts +0 -13
  118. package/src/lib/booking-types.ts +0 -36
  119. package/src/lib/currency.ts +0 -81
  120. package/src/lib/dap-descriptions.ts +0 -50
  121. package/src/lib/dap-itinerary-preview.ts +0 -315
  122. package/src/lib/dependent-add-on-api.ts +0 -434
  123. package/src/lib/env.ts +0 -96
  124. package/src/lib/firebase.ts +0 -20
  125. package/src/lib/job-application-api.ts +0 -83
  126. package/src/lib/manage-booking-embed-print.ts +0 -16
  127. package/src/lib/manage-booking-post-checkout.ts +0 -68
  128. package/src/lib/photo-dap-config.ts +0 -228
  129. package/src/lib/photo-packages.ts +0 -75
  130. package/src/lib/pickup/map-utils.ts +0 -56
  131. package/src/lib/pickup/marker-icons.ts +0 -19
  132. package/src/lib/product-descriptions.ts +0 -66
  133. package/src/lib/products-config.ts +0 -73
  134. package/src/providers/dependent-add-on-dialog-provider.tsx +0 -105
  135. package/src/radius.css +0 -5
  136. package/src/spacing.css +0 -7
  137. package/src/strings/en.json +0 -1774
  138. package/src/strings/es.json +0 -1573
  139. package/src/strings/fr.json +0 -1573
  140. package/src/strings/index.js +0 -23
  141. package/src/text-style.css +0 -56
  142. package/src/utils/currency-converter.ts +0 -101
@@ -1,2264 +0,0 @@
1
- 'use client';
2
-
3
- import React, { useState, useCallback, useRef, useEffect, useLayoutEffect } from 'react';
4
- import dynamic from 'next/dynamic';
5
- import { loadStripe } from '@stripe/stripe-js';
6
- import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
7
- import { formatCurrencyAmount } from '@/lib/currency';
8
- import { formatBookingRefForDisplay } from '@/lib/booking-ref';
9
- import type { Currency } from '@/lib/currency';
10
- import GetYourGuideIcon from '@/assets/icons/partner-logos/getyourguide.svg';
11
- import PhoneInputWithCountryComponent from '@/components/PhoneInputWithCountry';
12
- import {
13
- updateContactDetails,
14
- createBalancePaymentIntent,
15
- createManagePaymentIntent,
16
- cancelBookingPublic,
17
- respondToItineraryReview,
18
- type CommunicationMethod,
19
- type UpdateContactPayload,
20
- } from '@/lib/booking-api';
21
- import Button, { ButtonHoverColor } from '@/components/button';
22
- import ChangeBookingDialog from '@/components/booking/ChangeBookingDialog';
23
- import { manageBookingChangeSuccessMessage } from '@/lib/manage-booking-post-checkout';
24
- import type { ChangeFlowSelectionPreview } from '@/components/booking/BookingFlow';
25
- import { ENV } from '@/lib/env';
26
- import { storePendingPurchase } from '@/lib/analytics';
27
- import { useCompanyTimezone } from '@/contexts/CompanyContext';
28
- import { getItineraryStepLabel } from '@/lib/booking/itinerary-display';
29
- import styles from './BookingDetails.module.css';
30
- import { PostBookingDependentAddOnUpsell } from '@/components/PostBookingDependentAddOnUpsell';
31
- import defaultStrings from '@/strings';
32
-
33
- const stripePromise = ENV.STRIPE_PUBLISHABLE_KEY ? loadStripe(ENV.STRIPE_PUBLISHABLE_KEY) : null;
34
-
35
- /** Class added to html/body to lock scroll while cancel overlay is visible (see BookingDetails.module.css :global). */
36
- const CANCEL_OVERLAY_SCROLL_LOCK_CLASS = 'cancel-overlay-scroll-lock';
37
-
38
- const COMMUNICATION_OPTIONS: { value: CommunicationMethod; label: string }[] = [
39
- { value: 'EMAIL', label: 'Email' },
40
- { value: 'WHATSAPP', label: 'WhatsApp' },
41
- { value: 'SMS', label: 'SMS' },
42
- ];
43
-
44
- function WhatsAppIconSmall({ className }: { className?: string }) {
45
- return (
46
- <svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
47
- <path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
48
- </svg>
49
- );
50
- }
51
-
52
- const PickupLocationDialog = dynamic(() => import('@/components/PickupLocationDialog'), { ssr: false });
53
-
54
- export interface ReceiptLineItem {
55
- type: string;
56
- label: string;
57
- amount: number;
58
- quantity?: number | null;
59
- reference?: string | null;
60
- }
61
-
62
- export interface Receipt {
63
- currency: string;
64
- lineItems: ReceiptLineItem[];
65
- subtotalBeforeTax: number;
66
- taxAmount: number;
67
- taxRate?: number | null;
68
- taxIncluded: boolean;
69
- totalAmount: number;
70
- promoCode?: string | null;
71
- promoName?: string | null;
72
- }
73
-
74
- export interface PaymentMethodUsage {
75
- type: string;
76
- amount: number;
77
- currency: string;
78
- displayLabel?: string | null;
79
- reference?: string | null;
80
- /** ISO 8601 timestamp when payment/refund was processed */
81
- paidAt?: string | null;
82
- }
83
-
84
- export interface PaymentPlan {
85
- type: string;
86
- depositAmount: number;
87
- balanceAmount: number;
88
- chargeDate?: string | null;
89
- chargeStrategy: string;
90
- }
91
-
92
- export interface PaymentDisplay {
93
- status: string;
94
- plan?: PaymentPlan | null;
95
- methodUsages: PaymentMethodUsage[];
96
- balanceChargeScheduledAt?: string | null;
97
- balanceChargeAttempts: number;
98
- lastBalanceChargeAttempt?: string | null;
99
- }
100
-
101
- export interface CancellationPolicySnapshot {
102
- label: string;
103
- /** Window (in hours before booking start) to make changes to date/time etc. */
104
- changeWindowHoursBefore?: number | null;
105
- /** Legacy: hoursBeforeBooking + refundPercentage */
106
- tiers?: Array<{
107
- hoursBeforeBooking: number;
108
- refundPercentage: number;
109
- }>;
110
- /** Refund tiers: hours before booking + refund percent (e.g. from API/DynamoDB) */
111
- refundTiers?: Array<{
112
- hoursBefore: number;
113
- refundPercent: number;
114
- }>;
115
- }
116
-
117
- export interface Customer {
118
- firstName?: string | null;
119
- lastName?: string | null;
120
- email?: string | null;
121
- phoneNumber?: string | null;
122
- /** WhatsApp number if different from phone (for WhatsApp messaging) */
123
- whatsAppNumber?: string | null;
124
- }
125
-
126
- /** Dependent add-on rows from public/authenticated booking API */
127
- export interface DependentAddOnBookingDisplay {
128
- dependentAddOnBookingId: string;
129
- slotStart: string;
130
- slotEnd: string;
131
- quantity: number;
132
- /** Assigned photographer / resource label from booking API when present */
133
- resourceName?: string | null;
134
- status?: string;
135
- unitPrice?: number;
136
- currency?: string;
137
- /** Sum charged (e.g. from Stripe), including tax when applicable; from API `totalAmountPaid`. */
138
- totalAmountPaid?: number;
139
- dependentAddOnProductOptionId?: string | null;
140
- }
141
-
142
- export interface ItineraryDisplayStep {
143
- /** Optional - API may send label; otherwise we build from stepType + place */
144
- label?: string | null;
145
- time?: string | null;
146
- stepType?: string | null;
147
- /** Location name (e.g. "The Kenrick Hotel Banff", "Moraine Lake"). Backend sends this; we build display from stepType + place */
148
- place?: string | null;
149
- }
150
-
151
- export interface BookingItem {
152
- category: string; // e.g. "ADULT", "CHILD", "INFANT"
153
- count: number;
154
- }
155
-
156
- export interface BookingData {
157
- bookingReference: string;
158
- /** GetYourGuide booking reference when booked via them */
159
- gygBookingReference?: string | null;
160
- productId: string;
161
- /** Inventory availability slot id when the API persists it; best key for matching change-booking departure. */
162
- availabilityId?: string | null;
163
- /** Booked product option id (distinct from parent productId); used for change-booking preselection. Never a parent `p_` id. */
164
- productOptionId?: string | null;
165
- /** When the API exposes it, preferred resolved option id (same semantic as productOptionId). */
166
- resolvedProductOptionId?: string | null;
167
- companyId?: string | null;
168
- productName?: string | null;
169
- status: string;
170
- customer?: Customer | null;
171
- receipt: Receipt;
172
- payment: PaymentDisplay;
173
- dateTime?: string | null; // ISO 8601 format - availability date/time
174
- /**
175
- * Earliest committed departure used for refund/self-serve change cutoffs.
176
- * When present and earlier than dateTime, policies follow this time (e.g. after rescheduling to a later trip).
177
- */
178
- policyAnchorDateTime?: string | null;
179
- bookingItems?: BookingItem[] | null; // e.g. [{category: "ADULT", count: 2}, {category: "CHILD", count: 1}]
180
- /** Persisted add-on selections on the booking (when returned by API). */
181
- addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
182
- pickupLocationId?: string | null;
183
- /** Booked return leg (round-trip); used for change-booking “return time changed” detection. */
184
- returnAvailabilityId?: string | null;
185
- /** ISO return datetime when return availability id is not on the payload. */
186
- returnDateTime?: string | null;
187
- travelerHotel?: string | null;
188
- cancellationPolicyId?: string | null;
189
- cancellationPolicySnapshot?: CancellationPolicySnapshot | null;
190
- /** Pre-computed cancel window + refund (only when CONFIRMED and within policy) */
191
- cancellationInfo?: {
192
- canCancel: boolean;
193
- refundPercent: number;
194
- refundAmount: number;
195
- currency: string;
196
- refundTiers: Array<{ hoursBefore: number; refundPercent: number }>;
197
- } | null;
198
- communicationPreference?: string[] | null;
199
- itineraryDisplay?: ItineraryDisplayStep[] | null;
200
- createdAt: string;
201
- /** "STANDARD" | "PRIVATE_SHUTTLE" - from API */
202
- productType?: string | null;
203
- /** For private shuttles: passenger count (capacity), optional duration, whether count is being reviewed, and requested count (from planningNotes) for display */
204
- privateShuttleDetails?: { passengerCount: number; calculatedDurationMinutes?: number; passengerCountBeingReviewed?: boolean; requestedPassengerCount?: number } | null;
205
- /** Private shuttle draft + guest approval workflow */
206
- draftItinerary?: {
207
- destinations?: string[];
208
- planningNotes?: string | null;
209
- approvedAt?: string | null;
210
- reviewStatus?: 'DRAFT' | 'READY_FOR_REVIEW' | 'APPROVED' | 'CHANGES_REQUESTED' | null;
211
- guestChangeRequestComment?: string | null;
212
- submittedForReviewAt?: string | null;
213
- } | null;
214
- /** Calculated total duration in minutes (from reservation; for private shuttle total hours display) */
215
- calculatedDurationMinutes?: number | null;
216
- /** ISO 8601 timestamp when the booking was cancelled (from API) */
217
- cancelledAt?: string | null;
218
- /** ISO 8601 timestamp when the booking was last updated (used for "Cancelled at" when cancelledAt not available) */
219
- updatedAt?: string | null;
220
- /** Photo sessions / other products booked as add-ons to this shuttle booking */
221
- dependentAddOnBookings?: DependentAddOnBookingDisplay[] | null;
222
- /** Whether public self-serve change booking is allowed for this booking. */
223
- canChange?: boolean | null;
224
- /** Optional backend-provided reason when self-serve change is blocked. */
225
- changeBlockedReason?: string | null;
226
- /** Optional explicit cutoff datetime for self-serve changes. */
227
- changeByDateTime?: string | null;
228
- /** Optional summary of the most recent booking change. */
229
- lastChangeSummary?: {
230
- changedAt?: string | null;
231
- amountPaid?: number | null;
232
- currency?: string | null;
233
- summary?: string | null;
234
- } | null;
235
- }
236
-
237
- export interface BookingDetailsProps {
238
- booking: BookingData;
239
- currency: Currency;
240
- /** When true, shows "Booking Cancelled" title, same booking info, and Cancelled at; hides interactive sections. */
241
- isCancelled?: boolean;
242
- /** Force-hide the Change Booking section regardless of other eligibility checks. */
243
- hideChangeBookingSection?: boolean;
244
- /** Called when pickup location is updated (e.g. from dialog). Parent can refresh booking state. */
245
- onBookingUpdate?: (booking: BookingData) => void;
246
- /** After DAP checkout: show success banner until user leaves (parent sets from dap_success=1). */
247
- showAddOnPurchaseHighlight?: boolean;
248
- /**
249
- * Where to show the dependent add-on upsell on manage-booking: post-checkout first load (`top`) or
250
- * return visits (`bottom`). `null` hides it.
251
- */
252
- dependentAddOnUpsellPlacement?: 'top' | 'bottom' | null;
253
- onRefetch?: () => void | Promise<void>;
254
- /** One-shot success line from /manage-booking after paid change + URL clean (see manage-booking-post-checkout). */
255
- manageFlashMessage?: string | null;
256
- /** Full-screen light overlay while manage-booking parent refetches (e.g. after change dialog closes). */
257
- bookingRefreshPending?: boolean;
258
- }
259
-
260
- function PaymentForm({
261
- successUrl,
262
- onClose,
263
- payLabel,
264
- amount,
265
- currency,
266
- }: {
267
- successUrl: string;
268
- onClose: () => void;
269
- payLabel: string;
270
- amount: number;
271
- currency: Currency;
272
- }) {
273
- const stripe = useStripe();
274
- const elements = useElements();
275
- const [loading, setLoading] = useState(false);
276
- const [error, setError] = useState<string | null>(null);
277
-
278
- const handleSubmit = async (e: React.FormEvent) => {
279
- e.preventDefault();
280
- if (!stripe || !elements) return;
281
- setLoading(true);
282
- setError(null);
283
- const { error: submitError } = await elements.submit();
284
- if (submitError) {
285
- setError(submitError.message ?? 'Validation failed');
286
- setLoading(false);
287
- return;
288
- }
289
- // Store before redirect so success page can fire purchase event
290
- storePendingPurchase(amount, currency);
291
- const { error: confirmError } = await stripe.confirmPayment({
292
- elements,
293
- confirmParams: { return_url: successUrl },
294
- });
295
- if (confirmError) {
296
- setError(confirmError.message ?? 'Payment failed');
297
- }
298
- setLoading(false);
299
- };
300
-
301
- return (
302
- <form onSubmit={handleSubmit} className={styles.paymentForm}>
303
- <PaymentElement />
304
- {error && (
305
- <p className={styles.paymentFormError} role="alert">{error}</p>
306
- )}
307
- <div className={styles.paymentFormActions}>
308
- <button type="button" onClick={onClose} className={styles.paymentFormCancel}>
309
- Cancel
310
- </button>
311
- <button type="submit" disabled={!stripe || loading} className={styles.paymentFormSubmit}>
312
- {loading ? 'Paying...' : `${payLabel} (${formatCurrencyAmount(amount, currency)})`}
313
- </button>
314
- </div>
315
- </form>
316
- );
317
- }
318
-
319
- function isPickupOrDropOffStep(step: { stepType?: string | null }): boolean {
320
- return step.stepType === 'pickup' || step.stepType === 'drop_off';
321
- }
322
-
323
- /** Refund entries from payment.methodUsages (type REFUND or negative amount). */
324
- function getRefundEntries(methodUsages: PaymentMethodUsage[] | undefined): PaymentMethodUsage[] {
325
- if (!methodUsages?.length) return [];
326
- return methodUsages.filter(
327
- (m) => m.type === 'REFUND' || (typeof m.amount === 'number' && m.amount < 0)
328
- );
329
- }
330
-
331
- /** Format dateTime to show the date in MST (Mountain). Converts UTC to local day. */
332
- function formatBookingDate(dateTime: string | null | undefined): string {
333
- if (!dateTime) return '';
334
- try {
335
- const date = new Date(dateTime);
336
- return date.toLocaleDateString('en-US', {
337
- weekday: 'long',
338
- year: 'numeric',
339
- month: 'long',
340
- day: 'numeric',
341
- timeZone: 'America/Denver',
342
- });
343
- } catch {
344
- return dateTime;
345
- }
346
- }
347
-
348
- /** Format date for charge date display (e.g. "March 15, 2026"). */
349
- function formatChargeDate(dateTime: string | null | undefined): string {
350
- if (!dateTime) return '';
351
- try {
352
- const date = new Date(dateTime);
353
- return date.toLocaleDateString('en-US', {
354
- year: 'numeric',
355
- month: 'long',
356
- day: 'numeric',
357
- timeZone: 'America/Denver',
358
- });
359
- } catch {
360
- return dateTime;
361
- }
362
- }
363
-
364
- /** Format booking items to readable string (e.g. "2 adults, 1 child"). */
365
- function formatBookingItems(items: BookingItem[] | null | undefined): string {
366
- if (!items || items.length === 0) return '';
367
-
368
- const categoryLabels: Record<string, string> = {
369
- 'ADULT': 'adult',
370
- 'CHILD': 'child',
371
- 'INFANT': 'infant',
372
- 'SENIOR': 'senior',
373
- 'STUDENT': 'student',
374
- };
375
-
376
- const formatted = items
377
- .filter(item => item.count > 0)
378
- .map(item => {
379
- const label = categoryLabels[item.category] || item.category.toLowerCase();
380
- return `${item.count} ${label}${item.count !== 1 ? 's' : ''}`;
381
- });
382
-
383
- if (formatted.length === 0) return '';
384
- if (formatted.length === 1) return formatted[0];
385
- if (formatted.length === 2) return `${formatted[0]} and ${formatted[1]}`;
386
- return `${formatted.slice(0, -1).join(', ')}, and ${formatted[formatted.length - 1]}`;
387
- }
388
-
389
- /** Get pickup time from first itinerary step (pickup). May be single time, range (e.g. "8:00 AM - 9:00 AM"), or TBD. */
390
- function getPickupTimeFromItinerary(itinerary: ItineraryDisplayStep[] | null | undefined): string {
391
- if (!itinerary?.length) return '—';
392
- const pickupStep = itinerary.find(s => s.stepType === 'pickup');
393
- const t = pickupStep?.time?.trim();
394
- return t || 'TBD';
395
- }
396
-
397
- /** Get dropoff time from last itinerary step (drop_off). May be time or TBD. */
398
- function getDropoffTimeFromItinerary(itinerary: ItineraryDisplayStep[] | null | undefined): string {
399
- if (!itinerary?.length) return '—';
400
- const dropOffStep = [...itinerary].reverse().find(s => s.stepType === 'drop_off');
401
- const t = dropOffStep?.time?.trim();
402
- return t || 'TBD';
403
- }
404
-
405
- /** Format duration minutes to "X hours" or "X h Y min". */
406
- function formatDurationMinutes(minutes: number): string {
407
- if (minutes < 60) return `${minutes} min`;
408
- const h = Math.floor(minutes / 60);
409
- const m = minutes % 60;
410
- return m === 0 ? `${h} ${h === 1 ? 'hour' : 'hours'}` : `${h} h ${m} min`;
411
- }
412
-
413
- /** Format ISO date/time for display (e.g. "March 15, 2026, 2:30 PM"). */
414
- function formatDateTime(dateTime: string | null | undefined): string {
415
- if (!dateTime) return '—';
416
- try {
417
- const date = new Date(dateTime);
418
- return date.toLocaleString('en-US', {
419
- year: 'numeric',
420
- month: 'long',
421
- day: 'numeric',
422
- hour: 'numeric',
423
- minute: '2-digit',
424
- timeZone: 'America/Denver',
425
- });
426
- } catch {
427
- return dateTime;
428
- }
429
- }
430
-
431
- /** Session calendar date for DAP add-on (from slot start, company TZ). */
432
- function formatAddOnSessionDate(iso: string | undefined, timeZone: string): string {
433
- if (!iso?.trim()) return '—';
434
- try {
435
- return new Date(iso).toLocaleDateString('en-US', {
436
- weekday: 'short',
437
- year: 'numeric',
438
- month: 'short',
439
- day: 'numeric',
440
- timeZone,
441
- });
442
- } catch {
443
- return iso;
444
- }
445
- }
446
-
447
- /** Time-of-day only for DAP add-on (company TZ). */
448
- function formatAddOnSessionTime(iso: string | undefined, timeZone: string): string {
449
- if (!iso?.trim()) return '—';
450
- try {
451
- return new Date(iso).toLocaleTimeString('en-US', {
452
- hour: 'numeric',
453
- minute: '2-digit',
454
- timeZone,
455
- });
456
- } catch {
457
- return iso;
458
- }
459
- }
460
-
461
- function BookingAddOnsSection({
462
- addOns,
463
- showPurchaseHighlight,
464
- timeZone,
465
- }: {
466
- addOns: DependentAddOnBookingDisplay[];
467
- showPurchaseHighlight: boolean;
468
- timeZone: string;
469
- }) {
470
- if (addOns.length === 0 && !showPurchaseHighlight) return null;
471
- return (
472
- <div id="booking-add-ons" className={styles.section}>
473
- <h2 className={styles.sectionTitle}>
474
- <svg className={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
475
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
476
- </svg>
477
- Trip add-ons
478
- </h2>
479
- {showPurchaseHighlight && (
480
- <div className={styles.addOnSuccessBanner} role="status">
481
- <svg className={styles.addOnSuccessBannerIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
482
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
483
- </svg>
484
- <p className={styles.addOnSuccessBannerText}>
485
- <strong>Payment received.</strong> Your add-on is linked to this booking. Details appear below once processing
486
- finishes (usually within a few seconds).
487
- </p>
488
- </div>
489
- )}
490
- {addOns.length === 0 && showPurchaseHighlight ? (
491
- <p className={styles.addOnCardMeta}>
492
- If nothing appears here after a minute, refresh this page or contact us with your booking reference.
493
- </p>
494
- ) : null}
495
- {addOns.map((addon) => (
496
- <div key={addon.dependentAddOnBookingId} className={styles.addOnCard}>
497
- <h3 className={styles.addOnCardTitle}>Photo session add-on</h3>
498
- <p className={styles.addOnCardMeta}>
499
- <strong>Date:</strong> {formatAddOnSessionDate(addon.slotStart, timeZone)}
500
- </p>
501
- <p className={styles.addOnCardMeta}>
502
- <strong>Time:</strong>{' '}
503
- {formatAddOnSessionTime(addon.slotStart, timeZone)} –{' '}
504
- {formatAddOnSessionTime(addon.slotEnd, timeZone)}
505
- </p>
506
- {addon.status ? (
507
- <p className={styles.addOnCardMeta}>
508
- <strong>Status:</strong> {addon.status}
509
- </p>
510
- ) : null}
511
- {addon.totalAmountPaid != null && addon.currency ? (
512
- <p className={styles.addOnCardMeta}>
513
- <strong>Total:</strong>{' '}
514
- {formatCurrencyAmount(addon.totalAmountPaid, addon.currency.toUpperCase() as Currency)}
515
- </p>
516
- ) : addon.unitPrice != null && addon.currency ? (
517
- <p className={styles.addOnCardMeta}>
518
- <strong>Price:</strong>{' '}
519
- {formatCurrencyAmount(addon.unitPrice * addon.quantity, addon.currency.toUpperCase() as Currency)}
520
- </p>
521
- ) : null}
522
- {addon.resourceName ? (
523
- <p className={styles.addOnCardMeta}>
524
- <strong>Photographer:</strong> {addon.resourceName}
525
- </p>
526
- ) : null}
527
- </div>
528
- ))}
529
- </div>
530
- );
531
- }
532
-
533
- /** Format "cancel by" date/time from booking start and hours before. */
534
- function formatCancelByDate(bookingDateTime: string | null | undefined, hoursBefore: number): string {
535
- if (!bookingDateTime) return '';
536
- try {
537
- const start = new Date(bookingDateTime);
538
- const cancelBy = new Date(start.getTime() - hoursBefore * 60 * 60 * 1000);
539
- return cancelBy.toLocaleString('en-US', {
540
- year: 'numeric',
541
- month: 'long',
542
- day: 'numeric',
543
- hour: 'numeric',
544
- minute: '2-digit',
545
- });
546
- } catch {
547
- return '';
548
- }
549
- }
550
-
551
- export type ItineraryReviewStatus = 'DRAFT' | 'READY_FOR_REVIEW' | 'APPROVED' | 'CHANGES_REQUESTED';
552
-
553
- export function effectiveItineraryReviewStatus(draft: BookingData['draftItinerary']): ItineraryReviewStatus {
554
- if (!draft) return 'DRAFT';
555
- const rs = draft.reviewStatus;
556
- if (rs === 'READY_FOR_REVIEW' || rs === 'APPROVED' || rs === 'CHANGES_REQUESTED') return rs;
557
- if (draft.approvedAt?.trim()) return 'APPROVED';
558
- return 'DRAFT';
559
- }
560
-
561
- function policyAnchorIsEarlierThanTrip(anchor: string | null | undefined, trip: string | null | undefined): boolean {
562
- const a = anchor?.trim();
563
- const t = trip?.trim();
564
- if (!a || !t) return false;
565
- const am = Date.parse(a);
566
- const tm = Date.parse(t);
567
- if (Number.isNaN(am) || Number.isNaN(tm)) return false;
568
- return am < tm;
569
- }
570
-
571
- /**
572
- * Mirrors backend policy-cutoff base selection:
573
- * - prefer earliest of anchor/current while future
574
- * - if anchor is already past but current trip is still future, use current
575
- */
576
- function effectivePolicyDateTimeForCutoffs(booking: BookingData): string | null {
577
- const anchor = booking.policyAnchorDateTime?.trim() || booking.dateTime?.trim();
578
- const current = booking.dateTime?.trim();
579
- if (!anchor || !current) return null;
580
- const anchorMs = Date.parse(anchor);
581
- const currentMs = Date.parse(current);
582
- if (Number.isNaN(anchorMs) || Number.isNaN(currentMs)) return null;
583
- const nowMs = Date.now();
584
- const earlierMs = Math.min(anchorMs, currentMs);
585
- if (earlierMs > nowMs) return earlierMs === anchorMs ? anchor : current;
586
- if (anchorMs < nowMs && currentMs > nowMs) return current;
587
- return null;
588
- }
589
-
590
- // eslint-disable-next-line @typescript-eslint/no-unused-vars -- currency kept for API; display uses booking.receipt.currency
591
- export default function BookingDetails({
592
- booking,
593
- currency,
594
- isCancelled = false,
595
- hideChangeBookingSection = false,
596
- onBookingUpdate,
597
- onRefetch,
598
- showAddOnPurchaseHighlight = false,
599
- dependentAddOnUpsellPlacement = null,
600
- manageFlashMessage,
601
- bookingRefreshPending = false,
602
- }: BookingDetailsProps) {
603
- const companyTimeZone = useCompanyTimezone();
604
- const { receipt, itineraryDisplay, cancellationPolicySnapshot, cancellationInfo } = booking;
605
- const privateShuttleReviewStatus =
606
- booking.productType === 'PRIVATE_SHUTTLE' ? effectiveItineraryReviewStatus(booking.draftItinerary) : null;
607
- const dependentAddOns = booking.dependentAddOnBookings ?? [];
608
- const [pickupDialogOpen, setPickupDialogOpen] = useState(false);
609
- const [showPaymentModal, setShowPaymentModal] = useState(false);
610
- const [paymentClientSecret, setPaymentClientSecret] = useState<string | null>(null);
611
- const [paymentModalLabel, setPaymentModalLabel] = useState('');
612
- const [paymentLoading, setPaymentLoading] = useState<'deposit' | 'full' | 'balance' | null>(null);
613
- const [paymentError, setPaymentError] = useState<string | null>(null);
614
- const [contactCommPref, setContactCommPref] = useState<CommunicationMethod[]>(
615
- () => {
616
- const prefs = booking.communicationPreference?.filter((c): c is CommunicationMethod =>
617
- ['EMAIL', 'WHATSAPP', 'SMS'].includes(c)
618
- ) ?? [];
619
- return prefs.length > 0 ? prefs : ['EMAIL'];
620
- }
621
- );
622
- const [contactEmail, setContactEmail] = useState(booking.customer?.email ?? '');
623
- const [contactPhone, setContactPhone] = useState(booking.customer?.phoneNumber ?? '');
624
- const [contactWhatsApp, setContactWhatsApp] = useState(
625
- booking.customer?.whatsAppNumber ?? booking.customer?.phoneNumber ?? ''
626
- );
627
- const [contactSaving, setContactSaving] = useState(false);
628
- const [contactError, setContactError] = useState<string | null>(null);
629
- const [cancelling, setCancelling] = useState(false);
630
- const [cancelError, setCancelError] = useState<string | null>(null);
631
- const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
632
- const [showCancelOverlay, setShowCancelOverlay] = useState(false);
633
- const [changeFlowMessage, setChangeFlowMessage] = useState<string | null>(null);
634
- const [changeFlowSuccessMessage, setChangeFlowSuccessMessage] = useState<string | null>(null);
635
- const [changeDialogOpen, setChangeDialogOpen] = useState(false);
636
- /** Set on successful change; banner is shown in onClose after refetch so it survives parent re-renders. */
637
- const pendingChangeSuccessPreviewRef = useRef<ChangeFlowSelectionPreview | null | undefined>(undefined);
638
- const didCancelThisSessionRef = useRef(false);
639
- const [itineraryReviewSubmitting, setItineraryReviewSubmitting] = useState(false);
640
- const [itineraryReviewError, setItineraryReviewError] = useState<string | null>(null);
641
- const [requestChangesComment, setRequestChangesComment] = useState('');
642
- const [itineraryRequestChangesOpen, setItineraryRequestChangesOpen] = useState(false);
643
-
644
- const [isEmbeddedInIframe, setIsEmbeddedInIframe] = useState(false);
645
- useLayoutEffect(() => {
646
- if (typeof window === 'undefined') return;
647
- setIsEmbeddedInIframe(window.parent !== window.self);
648
- }, []);
649
-
650
- useEffect(() => {
651
- if (privateShuttleReviewStatus !== 'READY_FOR_REVIEW') {
652
- setItineraryRequestChangesOpen(false);
653
- }
654
- }, [privateShuttleReviewStatus]);
655
- const isGygBooking = Boolean(booking.gygBookingReference?.trim());
656
- const isPrivateShuttleBooking = booking.productType === 'PRIVATE_SHUTTLE';
657
- const effectivePolicyDateTime = effectivePolicyDateTimeForCutoffs(booking);
658
- const changeDeadlineMs = (() => {
659
- if (booking.changeByDateTime) {
660
- const parsed = Date.parse(booking.changeByDateTime);
661
- return Number.isNaN(parsed) ? null : parsed;
662
- }
663
- const hoursBefore = booking.cancellationPolicySnapshot?.changeWindowHoursBefore;
664
- if (hoursBefore == null || !effectivePolicyDateTime) return null;
665
- const bookingStartMs = Date.parse(effectivePolicyDateTime);
666
- if (Number.isNaN(bookingStartMs)) return null;
667
- return bookingStartMs - hoursBefore * 60 * 60 * 1000;
668
- })();
669
- const isPastChangeDeadline =
670
- changeDeadlineMs != null ? Date.now() > changeDeadlineMs : false;
671
- const shouldShowChangeBookingSection =
672
- !hideChangeBookingSection &&
673
- !isCancelled &&
674
- !isGygBooking &&
675
- !isPrivateShuttleBooking &&
676
- !isPastChangeDeadline;
677
-
678
- useEffect(() => {
679
- if (isCancelled && didCancelThisSessionRef.current) {
680
- didCancelThisSessionRef.current = false;
681
- setShowCancelOverlay(false);
682
- }
683
- }, [isCancelled]);
684
-
685
- useEffect(() => {
686
- const msg = manageFlashMessage?.trim();
687
- if (!msg) return;
688
- setChangeFlowSuccessMessage((prev) => prev || msg);
689
- if (typeof window !== 'undefined') {
690
- window.scrollTo({ top: 0, behavior: 'smooth' });
691
- }
692
- }, [manageFlashMessage]);
693
-
694
- useEffect(() => {
695
- if (typeof document === 'undefined') return;
696
- const { documentElement, body } = document;
697
- const classList = [documentElement.classList, body.classList];
698
- if (showCancelOverlay || bookingRefreshPending) {
699
- classList.forEach((c) => c.add(CANCEL_OVERLAY_SCROLL_LOCK_CLASS));
700
- } else {
701
- classList.forEach((c) => c.remove(CANCEL_OVERLAY_SCROLL_LOCK_CLASS));
702
- }
703
- return () => {
704
- classList.forEach((c) => c.remove(CANCEL_OVERLAY_SCROLL_LOCK_CLASS));
705
- };
706
- }, [showCancelOverlay, bookingRefreshPending]);
707
-
708
- const openPickupDialog = useCallback(() => setPickupDialogOpen(true), []);
709
-
710
- const closePickupDialog = useCallback(() => setPickupDialogOpen(false), []);
711
-
712
- const closePaymentModal = useCallback(() => {
713
- setShowPaymentModal(false);
714
- setPaymentClientSecret(null);
715
- setPaymentError(null);
716
- setPaymentModalLabel('');
717
- }, []);
718
-
719
- const handlePayDeposit = useCallback(async () => {
720
- const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
721
- const ln = booking.customer?.lastName?.trim();
722
- if (!ln) {
723
- setPaymentError('Last name is required');
724
- return;
725
- }
726
- setPaymentLoading('deposit');
727
- setPaymentError(null);
728
- try {
729
- const res = await createManagePaymentIntent(ref, ln, 'deposit');
730
- setPaymentClientSecret(res.clientSecret);
731
- setPaymentModalLabel('Pay deposit');
732
- setShowPaymentModal(true);
733
- } catch (err) {
734
- setPaymentError(err instanceof Error ? err.message : 'Failed to start payment');
735
- } finally {
736
- setPaymentLoading(null);
737
- }
738
- }, [booking.bookingReference, booking.customer?.lastName]);
739
-
740
- const handlePayFull = useCallback(async () => {
741
- const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
742
- const ln = booking.customer?.lastName?.trim();
743
- if (!ln) {
744
- setPaymentError('Last name is required');
745
- return;
746
- }
747
- setPaymentLoading('full');
748
- setPaymentError(null);
749
- try {
750
- const res = await createManagePaymentIntent(ref, ln, 'full');
751
- setPaymentClientSecret(res.clientSecret);
752
- setPaymentModalLabel('Pay full balance');
753
- setShowPaymentModal(true);
754
- } catch (err) {
755
- setPaymentError(err instanceof Error ? err.message : 'Failed to start payment');
756
- } finally {
757
- setPaymentLoading(null);
758
- }
759
- }, [booking.bookingReference, booking.customer?.lastName]);
760
-
761
- const handlePayBalance = useCallback(async () => {
762
- const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
763
- const ln = booking.customer?.lastName?.trim();
764
- if (!ln) {
765
- setPaymentError('Last name is required');
766
- return;
767
- }
768
- setPaymentLoading('balance');
769
- setPaymentError(null);
770
- try {
771
- const res = await createBalancePaymentIntent(ref, ln);
772
- setPaymentClientSecret(res.clientSecret);
773
- setPaymentModalLabel('Pay balance');
774
- setShowPaymentModal(true);
775
- } catch (err) {
776
- setPaymentError(err instanceof Error ? err.message : 'Failed to start payment');
777
- } finally {
778
- setPaymentLoading(null);
779
- }
780
- }, [booking.bookingReference, booking.customer?.lastName]);
781
-
782
- // Sync contact state when booking changes (e.g. after save)
783
- React.useEffect(() => {
784
- const prefs = booking.communicationPreference?.filter((c): c is CommunicationMethod =>
785
- ['EMAIL', 'WHATSAPP', 'SMS'].includes(c)
786
- ) ?? [];
787
- setContactCommPref(prefs.length > 0 ? prefs : ['EMAIL']);
788
- setContactEmail(booking.customer?.email ?? '');
789
- setContactPhone(booking.customer?.phoneNumber ?? '');
790
- setContactWhatsApp(booking.customer?.whatsAppNumber ?? booking.customer?.phoneNumber ?? '');
791
- }, [booking]);
792
-
793
- const toggleCommPref = useCallback((method: CommunicationMethod) => {
794
- setContactCommPref((prev) =>
795
- prev.includes(method) ? prev.filter((m) => m !== method) : [...prev, method]
796
- );
797
- }, []);
798
-
799
- const handleSaveContact = useCallback(async () => {
800
- const lastName = booking.customer?.lastName ?? '';
801
- if (!lastName.trim()) {
802
- setContactError('Last name is required to update contact details');
803
- return;
804
- }
805
- const emailTrimmed = contactEmail.trim();
806
- if (!emailTrimmed) {
807
- setContactError('Email is required');
808
- return;
809
- }
810
- if (contactCommPref.length === 0) {
811
- setContactError('Please select at least one preferred communication method');
812
- return;
813
- }
814
- if (contactCommPref.includes('SMS') && !contactPhone.trim()) {
815
- setContactError('Phone number is required when SMS is selected');
816
- return;
817
- }
818
- if (contactCommPref.includes('WHATSAPP') && !contactWhatsApp.trim()) {
819
- setContactError('WhatsApp number is required when WhatsApp is selected');
820
- return;
821
- }
822
- setContactSaving(true);
823
- setContactError(null);
824
- try {
825
- const payload: UpdateContactPayload = {
826
- communicationPreference: contactCommPref.length > 0 ? contactCommPref : undefined,
827
- email: emailTrimmed,
828
- phoneNumber: contactPhone.trim() || null,
829
- whatsAppNumber: contactWhatsApp.trim() || null,
830
- };
831
- const updated = await updateContactDetails(booking.bookingReference, lastName, payload);
832
- const updatedBooking = updated as BookingData;
833
- // Sync local state from API response immediately so UI reflects saved values
834
- if (updatedBooking.customer) {
835
- const c = updatedBooking.customer as { email?: string; phoneNumber?: string; phone_number?: string; whatsAppNumber?: string; whats_app_number?: string };
836
- setContactEmail(c.email ?? '');
837
- setContactPhone(c.phoneNumber ?? c.phone_number ?? '');
838
- setContactWhatsApp(c.whatsAppNumber ?? c.whats_app_number ?? c.phoneNumber ?? c.phone_number ?? '');
839
- }
840
- if (updatedBooking.communicationPreference?.length) {
841
- const prefs = updatedBooking.communicationPreference.filter((c): c is CommunicationMethod => ['EMAIL', 'WHATSAPP', 'SMS'].includes(c));
842
- setContactCommPref(prefs.length > 0 ? prefs : ['EMAIL']);
843
- }
844
- onBookingUpdate?.(updatedBooking);
845
- } catch (e) {
846
- setContactError(e instanceof Error ? e.message : 'Failed to update contact details');
847
- } finally {
848
- setContactSaving(false);
849
- }
850
- }, [booking, contactCommPref, contactEmail, contactPhone, contactWhatsApp, onBookingUpdate]);
851
-
852
- const handleApproveItinerary = useCallback(async () => {
853
- const lastName = booking.customer?.lastName?.trim();
854
- if (!lastName) {
855
- setItineraryReviewError('Last name is required. Open this page from your manage-booking link.');
856
- return;
857
- }
858
- setItineraryReviewSubmitting(true);
859
- setItineraryReviewError(null);
860
- try {
861
- const data = await respondToItineraryReview(booking.bookingReference, lastName, 'approve');
862
- onBookingUpdate?.(data as BookingData);
863
- onRefetch?.();
864
- } catch (e) {
865
- setItineraryReviewError(e instanceof Error ? e.message : 'Request failed');
866
- } finally {
867
- setItineraryReviewSubmitting(false);
868
- }
869
- }, [booking.bookingReference, booking.customer?.lastName, onBookingUpdate, onRefetch]);
870
-
871
- const handleRequestItineraryChanges = useCallback(async () => {
872
- const c = requestChangesComment.trim();
873
- if (!c) {
874
- setItineraryReviewError('Please describe what you would like changed.');
875
- return;
876
- }
877
- const lastName = booking.customer?.lastName?.trim();
878
- if (!lastName) {
879
- setItineraryReviewError('Last name is required. Open this page from your manage-booking link.');
880
- return;
881
- }
882
- setItineraryReviewSubmitting(true);
883
- setItineraryReviewError(null);
884
- try {
885
- const data = await respondToItineraryReview(booking.bookingReference, lastName, 'request_changes', c);
886
- onBookingUpdate?.(data as BookingData);
887
- setRequestChangesComment('');
888
- onRefetch?.();
889
- } catch (e) {
890
- setItineraryReviewError(e instanceof Error ? e.message : 'Request failed');
891
- } finally {
892
- setItineraryReviewSubmitting(false);
893
- }
894
- }, [booking.bookingReference, booking.customer?.lastName, onBookingUpdate, onRefetch, requestChangesComment]);
895
-
896
- const handleCancelBooking = useCallback(async () => {
897
- const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
898
- const ln = booking.customer?.lastName?.trim();
899
- if (!ln) {
900
- setCancelError('Last name is required');
901
- return;
902
- }
903
- setCancelError(null);
904
- if (typeof window !== 'undefined') window.scrollTo(0, 0);
905
- setCancelConfirmOpen(false);
906
- setShowCancelOverlay(true);
907
- setCancelling(true);
908
- try {
909
- await cancelBookingPublic(ref, ln);
910
- didCancelThisSessionRef.current = true;
911
- await onRefetch?.();
912
- } catch (err) {
913
- setShowCancelOverlay(false);
914
- setCancelError(err instanceof Error ? err.message : 'Failed to cancel booking');
915
- } finally {
916
- setCancelling(false);
917
- }
918
- }, [booking.bookingReference, booking.customer?.lastName, onRefetch]);
919
-
920
- const openCancelConfirmDialog = useCallback(() => {
921
- setCancelError(null);
922
- setCancelConfirmOpen(true);
923
- }, []);
924
-
925
- const closeCancelConfirmDialog = useCallback(() => {
926
- setCancelConfirmOpen(false);
927
- setCancelError(null);
928
- }, []);
929
-
930
- const headerIcon = isCancelled ? (
931
- <div className={styles.cancelledIcon}>
932
- <svg className={styles.cancelledSvg} fill="none" stroke="currentColor" viewBox="0 0 24 24">
933
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
934
- </svg>
935
- </div>
936
- ) : (
937
- <div className={styles.checkmarkIcon}>
938
- <svg className={styles.checkmarkSvg} fill="none" stroke="currentColor" viewBox="0 0 24 24">
939
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
940
- </svg>
941
- </div>
942
- );
943
-
944
- const managePrint = defaultStrings.manageBooking as {
945
- printSummary: string;
946
- printSummaryAria: string;
947
- };
948
- const printSummaryControl = (
949
- <div className={styles.printSummaryWrap}>
950
- <button
951
- type="button"
952
- className={styles.printSummaryButton}
953
- onClick={() => {
954
- if (typeof window !== 'undefined') window.print();
955
- }}
956
- aria-label={managePrint.printSummaryAria}
957
- >
958
- <svg
959
- className={styles.printSummaryIcon}
960
- viewBox="0 0 24 24"
961
- fill="none"
962
- stroke="currentColor"
963
- strokeWidth={2}
964
- strokeLinecap="round"
965
- strokeLinejoin="round"
966
- aria-hidden
967
- >
968
- <polyline points="6 9 6 2 18 2 18 9" />
969
- <path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2" />
970
- <rect x="6" y="14" width="12" height="8" />
971
- </svg>
972
- {managePrint.printSummary}
973
- </button>
974
- </div>
975
- );
976
-
977
- const bookingDetailsBlock = (
978
- <div className={styles.bookingDetails}>
979
- {booking.customer?.firstName && booking.customer?.lastName && (
980
- <div className={styles.bookingDetailRow}>
981
- <span className={styles.bookingDetailLabel}>Name:</span>
982
- <span className={styles.bookingDetailValue}>{booking.customer.firstName} {booking.customer.lastName}</span>
983
- </div>
984
- )}
985
- {booking.dateTime && (
986
- <div className={styles.bookingDetailRow}>
987
- <span className={styles.bookingDetailLabel}>Date:</span>
988
- <span className={styles.bookingDetailValue}>{formatBookingDate(booking.dateTime)}</span>
989
- </div>
990
- )}
991
- {booking.productName && (
992
- <div className={styles.bookingDetailRow}>
993
- <span className={styles.bookingDetailLabel}>Tour:</span>
994
- <span className={styles.bookingDetailValue}>{booking.productName}</span>
995
- </div>
996
- )}
997
- {(() => {
998
- const isPrivateShuttle = booking.productType === 'PRIVATE_SHUTTLE';
999
- const passengerCount = booking.privateShuttleDetails?.passengerCount;
1000
- const requestedPassengerCount = booking.privateShuttleDetails?.requestedPassengerCount;
1001
- const displayCount = requestedPassengerCount ?? passengerCount;
1002
- const isBeingReviewed = booking.privateShuttleDetails?.passengerCountBeingReviewed === true;
1003
- if (isPrivateShuttle && displayCount != null) {
1004
- return (
1005
- <div className={styles.bookingDetailRow}>
1006
- <span className={styles.bookingDetailLabel}>Guests:</span>
1007
- <span className={styles.bookingDetailValue}>
1008
- {displayCount} {displayCount === 1 ? 'passenger' : 'passengers'}
1009
- {isBeingReviewed && (
1010
- <span className="text-amber-600 text-sm ml-1">(being reviewed)</span>
1011
- )}
1012
- </span>
1013
- </div>
1014
- );
1015
- }
1016
- if (booking.bookingItems && booking.bookingItems.length > 0) {
1017
- return (
1018
- <div className={styles.bookingDetailRow}>
1019
- <span className={styles.bookingDetailLabel}>Guests:</span>
1020
- <span className={styles.bookingDetailValue}>{formatBookingItems(booking.bookingItems)}</span>
1021
- </div>
1022
- );
1023
- }
1024
- return null;
1025
- })()}
1026
- {booking.itineraryDisplay && booking.itineraryDisplay.length > 0 && (() => {
1027
- const isPrivateShuttleForTooltip = booking.productType === 'PRIVATE_SHUTTLE';
1028
- const hasPickupStep = booking.itineraryDisplay!.some((s) => s.stepType === 'pickup');
1029
- const hasDropoffStep = booking.itineraryDisplay!.some((s) => s.stepType === 'drop_off');
1030
- const showPickupTooltip = !isCancelled && !isPrivateShuttleForTooltip && !booking.pickupLocationId && hasPickupStep;
1031
- const showDropoffTooltip = !isCancelled && !isPrivateShuttleForTooltip && !booking.pickupLocationId && hasDropoffStep;
1032
- const tooltipContent = (
1033
- <>
1034
- Approximate time - will be finalized when you{' '}
1035
- <a href="#pickup-details" className={styles.timeTooltipLink}>select your pickup location</a>.
1036
- </>
1037
- );
1038
- return (
1039
- <>
1040
- <div className={styles.bookingDetailRow}>
1041
- <span className={styles.bookingDetailLabel}>Pickup time:</span>
1042
- <span className={styles.bookingDetailValue}>
1043
- {getPickupTimeFromItinerary(booking.itineraryDisplay)}
1044
- {showPickupTooltip && (
1045
- <span className={styles.timeTooltipWrap}>
1046
- <span className={styles.timeTooltipIcon} aria-label="Approximate time; will be finalized when you select a pickup location in the Pickup Details section below">
1047
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1048
- <path d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
1049
- </svg>
1050
- </span>
1051
- <span className={styles.timeTooltip} role="tooltip">{tooltipContent}</span>
1052
- </span>
1053
- )}
1054
- </span>
1055
- </div>
1056
- <div className={styles.bookingDetailRow}>
1057
- <span className={styles.bookingDetailLabel}>Dropoff time:</span>
1058
- <span className={styles.bookingDetailValue}>
1059
- {getDropoffTimeFromItinerary(booking.itineraryDisplay)}
1060
- {showDropoffTooltip && (
1061
- <span className={styles.timeTooltipWrap}>
1062
- <span className={styles.timeTooltipIcon} aria-label="Approximate time; will be finalized when you select a pickup location in the Pickup Details section below">
1063
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1064
- <path d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
1065
- </svg>
1066
- </span>
1067
- <span className={styles.timeTooltip} role="tooltip">{tooltipContent}</span>
1068
- </span>
1069
- )}
1070
- </span>
1071
- </div>
1072
- {booking.productType === 'PRIVATE_SHUTTLE' && (() => {
1073
- const duration = booking.calculatedDurationMinutes ?? booking.privateShuttleDetails?.calculatedDurationMinutes;
1074
- if (duration == null || duration <= 0) return null;
1075
- return (
1076
- <div className={styles.bookingDetailRow}>
1077
- <span className={styles.bookingDetailLabel}>Total hours:</span>
1078
- <span className={styles.bookingDetailValue}>
1079
- {formatDurationMinutes(duration)}
1080
- </span>
1081
- </div>
1082
- );
1083
- })()}
1084
- </>
1085
- );
1086
- })()}
1087
- {isCancelled && (
1088
- <div className={styles.bookingDetailRow}>
1089
- <span className={styles.bookingDetailLabel}>Cancelled at:</span>
1090
- <span className={styles.bookingDetailValue}>{formatDateTime(booking.cancelledAt ?? booking.updatedAt)}</span>
1091
- </div>
1092
- )}
1093
- </div>
1094
- );
1095
-
1096
- if (isCancelled) {
1097
- const paymentStatus = (booking.payment?.status ?? '').toUpperCase();
1098
- const hasRemainingBalance = (booking.payment?.plan?.balanceAmount ?? 0) > 0;
1099
- const treatedAsFullyPaid = paymentStatus === 'FULLY_PAID' || (paymentStatus === 'DEPOSIT_PAID' && !hasRemainingBalance);
1100
- const statusLabel = paymentStatus === 'REFUNDED' ? 'Refunded' : treatedAsFullyPaid ? 'Fully paid' : 'Cancelled';
1101
- const statusPillClass = paymentStatus === 'REFUNDED' || treatedAsFullyPaid ? styles.paymentStatusPillPaid : styles.paymentStatusPillAwaiting;
1102
-
1103
- return (
1104
- <div className={styles.container}>
1105
- <div className={styles.section}>
1106
- <div className={styles.headerContent}>
1107
- {headerIcon}
1108
- <div>
1109
- <h1 className={styles.title}>Booking Cancelled</h1>
1110
- <p className={styles.reference}>Via Via booking reference: {formatBookingRefForDisplay(booking.bookingReference)}</p>
1111
- {!isEmbeddedInIframe ? printSummaryControl : null}
1112
- </div>
1113
- </div>
1114
- {bookingDetailsBlock}
1115
- </div>
1116
-
1117
- <BookingAddOnsSection
1118
- addOns={dependentAddOns}
1119
- showPurchaseHighlight={showAddOnPurchaseHighlight}
1120
- timeZone={companyTimeZone}
1121
- />
1122
-
1123
- {/* Payment Summary (receipt) for cancelled bookings */}
1124
- <div className={styles.section}>
1125
- <div className={styles.paymentSummaryHeader}>
1126
- <h2 className={styles.sectionTitle}>
1127
- <svg className={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1128
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
1129
- </svg>
1130
- Payment Summary
1131
- </h2>
1132
- <span className={`${styles.paymentStatusPill} ${statusPillClass}`}>{statusLabel}</span>
1133
- </div>
1134
- <div className={styles.priceSummary}>
1135
- {receipt.lineItems.map((item, index) => (
1136
- <div key={index} className={styles.priceLine}>
1137
- <span className={styles.priceLabel}>
1138
- {item.label}
1139
- {item.quantity && item.quantity > 1 && !/\sx\s*\d+$/i.test(item.label) && ` × ${item.quantity}`}
1140
- </span>
1141
- <span className={styles.priceAmount}>
1142
- {formatCurrencyAmount(item.amount, receipt.currency as Currency)}
1143
- </span>
1144
- </div>
1145
- ))}
1146
- {receipt.taxAmount > 0 && (() => {
1147
- const lineItemsIncludeTax = receipt.lineItems.some((item) => {
1148
- const lbl = (item.label ?? '').toLowerCase();
1149
- const type = (item.type ?? '').toLowerCase();
1150
- return /\b(gst|tax|hst|pst|vat)\b/.test(lbl) || /\b(gst|tax|hst|pst|vat)\b/.test(type);
1151
- });
1152
- if (lineItemsIncludeTax) return null;
1153
- return (
1154
- <>
1155
- <div className={styles.priceLine}>
1156
- <span className={styles.priceLabel}>Subtotal</span>
1157
- <span className={styles.priceAmount}>
1158
- {formatCurrencyAmount(receipt.subtotalBeforeTax, receipt.currency as Currency)}
1159
- </span>
1160
- </div>
1161
- <div className={styles.priceLine}>
1162
- <span className={styles.priceLabel}>Tax</span>
1163
- <span className={styles.priceAmount}>
1164
- {formatCurrencyAmount(receipt.taxAmount, receipt.currency as Currency)}
1165
- </span>
1166
- </div>
1167
- </>
1168
- );
1169
- })()}
1170
- <div className={`${styles.priceLine} ${styles.totalLine}`}>
1171
- <span className={styles.totalLabel}>Total</span>
1172
- <span className={styles.totalAmount}>
1173
- {formatCurrencyAmount(receipt.totalAmount, receipt.currency as Currency)}
1174
- </span>
1175
- </div>
1176
- {booking.payment?.status === 'DEPOSIT_PAID' && (booking.payment?.plan?.balanceAmount ?? 0) > 0 && (booking.payment?.plan?.depositAmount ?? 0) > 0 && (
1177
- <div className={styles.priceLine}>
1178
- <span className={styles.priceLabel}>Deposit paid</span>
1179
- <span className={styles.priceAmount}>
1180
- -{formatCurrencyAmount(booking.payment!.plan!.depositAmount!, receipt.currency as Currency)}
1181
- </span>
1182
- </div>
1183
- )}
1184
- {getRefundEntries(booking.payment?.methodUsages).map((entry, index) => (
1185
- <div key={index} className={styles.priceLine}>
1186
- <span className={styles.priceLabel}>
1187
- {entry.displayLabel?.trim() || 'Refund'}
1188
- {entry.paidAt && ` (${formatDateTime(entry.paidAt)})`}
1189
- </span>
1190
- <span className={styles.priceAmount}>
1191
- {formatCurrencyAmount(entry.amount, (entry.currency || receipt.currency) as Currency)}
1192
- </span>
1193
- </div>
1194
- ))}
1195
- </div>
1196
- </div>
1197
- {bookingRefreshPending && !showCancelOverlay && (
1198
- <div
1199
- className={styles.bookingRefreshOverlay}
1200
- role="status"
1201
- aria-live="polite"
1202
- aria-busy="true"
1203
- aria-label="Updating booking"
1204
- >
1205
- <div className={styles.cancelOverlaySpinner} aria-hidden />
1206
- <p className={styles.cancelOverlayText}>Updating your booking…</p>
1207
- </div>
1208
- )}
1209
- </div>
1210
- );
1211
- }
1212
-
1213
- return (
1214
- <div className={styles.container}>
1215
- {changeFlowSuccessMessage && (
1216
- <div className={styles.changeBookingSuccess} role="status" aria-live="polite">
1217
- {changeFlowSuccessMessage}
1218
- </div>
1219
- )}
1220
- {/* Header */}
1221
- <div className={styles.section}>
1222
- <div className={styles.headerContent}>
1223
- {headerIcon}
1224
- <div>
1225
- <h1 className={styles.title}>Booking Confirmed</h1>
1226
- <p className={styles.reference}>Via Via booking reference: {formatBookingRefForDisplay(booking.bookingReference)}</p>
1227
- {!isEmbeddedInIframe ? printSummaryControl : null}
1228
- </div>
1229
- </div>
1230
- {bookingDetailsBlock}
1231
- </div>
1232
-
1233
- {dependentAddOnUpsellPlacement === 'top' ? (
1234
- <PostBookingDependentAddOnUpsell booking={booking} enabled />
1235
- ) : null}
1236
-
1237
- <BookingAddOnsSection
1238
- addOns={dependentAddOns}
1239
- showPurchaseHighlight={showAddOnPurchaseHighlight}
1240
- timeZone={companyTimeZone}
1241
- />
1242
-
1243
- {/* GetYourGuide - separate section when booked via GYG */}
1244
- {booking.gygBookingReference?.trim() && (
1245
- <div id="getyourguide" className={`${styles.section} ${styles.gygSection}`}>
1246
- <h2 className={styles.sectionTitle}>
1247
- <GetYourGuideIcon className={styles.gygSectionIcon} width={20} height={20} aria-hidden />
1248
- You Booked via GetYourGuide
1249
- </h2>
1250
- <div className={styles.gygSectionContent}>
1251
- <p className={styles.gygRefLine}>
1252
- <strong>GetYourGuide booking reference:</strong> {booking.gygBookingReference.trim()}
1253
- </p>
1254
- <p className={styles.gygNote}>
1255
- <strong>NOTE:</strong> Your booking was made via a <strong>third party booking platform</strong>. Any changes to your booking must be made{' '}
1256
- <a href="https://www.getyourguide.com/contact#booking-management" target="_blank" rel="noopener noreferrer" className={styles.gygNoteLink}>here</a>
1257
- {' '}using the <strong>GetYourGuide booking reference</strong> code and <strong>PIN</strong> sent to your email by GetYourGuide.
1258
- </p>
1259
- <a
1260
- href="https://www.getyourguide.com/contact#booking-management"
1261
- target="_blank"
1262
- rel="noopener noreferrer"
1263
- className={styles.gygManageLink}
1264
- >
1265
- <svg className={styles.gygManageIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
1266
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
1267
- </svg>
1268
- Manage Your Booking With GetYourGuide
1269
- </a>
1270
- </div>
1271
- </div>
1272
- )}
1273
-
1274
- {/* Your Itinerary */}
1275
- {itineraryDisplay && itineraryDisplay.length > 0 && (
1276
- <div className={styles.section}>
1277
- <div className={styles.itinerarySectionHeader}>
1278
- <h2 className={styles.sectionTitle}>
1279
- <svg className={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1280
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
1281
- </svg>
1282
- Your Itinerary
1283
- </h2>
1284
- {privateShuttleReviewStatus && privateShuttleReviewStatus !== 'APPROVED' && (
1285
- <span className={styles.draftPill}>
1286
- {privateShuttleReviewStatus === 'READY_FOR_REVIEW'
1287
- ? 'REVIEW'
1288
- : privateShuttleReviewStatus === 'CHANGES_REQUESTED'
1289
- ? 'UPDATING'
1290
- : 'DRAFT'}
1291
- </span>
1292
- )}
1293
- {privateShuttleReviewStatus === 'APPROVED' && (
1294
- <span className={`${styles.paymentStatusPill} ${styles.paymentStatusPillPaid}`}>
1295
- Confirmed
1296
- </span>
1297
- )}
1298
- </div>
1299
- {privateShuttleReviewStatus === 'DRAFT' && (
1300
- <div className={styles.draftItineraryBanner}>
1301
- <svg className={styles.draftItineraryIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
1302
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
1303
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
1304
- </svg>
1305
- <p className={styles.draftItineraryText}>
1306
- <b>Your itinerary is in draft. You can still get a full refund on your deposit until you approve the final itinerary.</b><br />
1307
- Our team is working on your custom itinerary details. You will be notified when it is ready for your approval within 72 hours.
1308
- </p>
1309
- </div>
1310
- )}
1311
- {privateShuttleReviewStatus === 'CHANGES_REQUESTED' && (
1312
- <div className={styles.draftItineraryBanner}>
1313
- <p className={styles.draftItineraryText}>
1314
- <b>We received your feedback.</b> Our team will update your itinerary and send an updated version for your approval.
1315
- </p>
1316
- </div>
1317
- )}
1318
- <div className={styles.itineraryList}>
1319
- {itineraryDisplay.flatMap((step, index) => {
1320
- const isUncertainStep = !booking.pickupLocationId && isPickupOrDropOffStep(step);
1321
- const showPickupLink = !booking.pickupLocationId && step.place?.trim() === 'your_pickup_location';
1322
- const placeDisplay = step.place?.trim() === 'your_pickup_location' ? 'your pickup location' : step.place?.trim() === 'the_destination' ? 'the destination' : step.place?.trim() ?? '';
1323
- const isDraftStep = step.stepType === 'draft' || (booking.productType === 'PRIVATE_SHUTTLE' && (!step.time?.trim() || step.time?.trim().toUpperCase() === 'TBD'));
1324
-
1325
- // Draft destinations: 1 line per destination with TBD in the time column (same layout as other steps)
1326
- const isExpandableDraft = (step.stepType === 'draft' || (step.stepType === 'arrive' && step.time?.trim().toUpperCase() === 'TBD')) && placeDisplay;
1327
- if (isExpandableDraft) {
1328
- const destinations = placeDisplay.split(',').map((s) => s.trim()).filter(Boolean);
1329
- return destinations.map((dest, di) => (
1330
- <div key={`${index}-${di}`} className={styles.itineraryItem}>
1331
- <span className={styles.itineraryLabel}>{dest}</span>
1332
- <span className={styles.itineraryTime}><span className={styles.tbdText}>TBD</span></span>
1333
- </div>
1334
- ));
1335
- }
1336
-
1337
- const locationLabel = getItineraryStepLabel(step);
1338
- const labelContent = showPickupLink ? (
1339
- step.stepType === 'pickup' ? (
1340
- <>Pickup at <a href="#pickup-details" className={styles.pickupLink}>your pickup location</a></>
1341
- ) : step.stepType === 'drop_off' ? (
1342
- <>Drop off at <a href="#pickup-details" className={styles.pickupLink}>your pickup location</a></>
1343
- ) : (
1344
- locationLabel
1345
- )
1346
- ) : (
1347
- locationLabel
1348
- );
1349
- const timeContent = isUncertainStep && !step.time ? (
1350
- <>
1351
- <span className={styles.tbdIcon} title="Time not set - select your pickup location in the Pickup Details section below for an estimate.">
1352
- <svg className={styles.timeIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
1353
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
1354
- </svg>
1355
- </span>
1356
- <span className={styles.tbdText}>TBD</span>
1357
- </>
1358
- ) : step.time ? (
1359
- <span>{step.time}</span>
1360
- ) : isDraftStep ? (
1361
- <span className={styles.tbdText}>TBD</span>
1362
- ) : null;
1363
- return [
1364
- <div
1365
- key={index}
1366
- className={`${styles.itineraryItem} ${isDraftStep ? styles.itineraryItemDraft : ''}`}
1367
- >
1368
- {isDraftStep ? (
1369
- <>
1370
- <span className={styles.itineraryLabel}>{labelContent}</span>
1371
- <div className={styles.itineraryTimeBlock}>{timeContent}</div>
1372
- </>
1373
- ) : (
1374
- <>
1375
- <span className={styles.itineraryLabel}>{labelContent}</span>
1376
- <span className={styles.itineraryTime}>{timeContent}</span>
1377
- </>
1378
- )}
1379
- </div>,
1380
- ];
1381
- })}
1382
- </div>
1383
- {privateShuttleReviewStatus === 'READY_FOR_REVIEW' && (
1384
- <div
1385
- className={`${styles.draftItineraryBanner} ${styles.draftItineraryBannerBelowList} ${styles.draftItineraryBannerActionRequired}`}
1386
- >
1387
- <div className={styles.draftItineraryReviewFullWidth}>
1388
- <h3 className={styles.draftItineraryActionTitle}>
1389
- <svg
1390
- className={styles.icon}
1391
- fill="none"
1392
- stroke="currentColor"
1393
- viewBox="0 0 24 24"
1394
- aria-hidden
1395
- >
1396
- <path
1397
- strokeLinecap="round"
1398
- strokeLinejoin="round"
1399
- strokeWidth={2}
1400
- d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
1401
- />
1402
- </svg>
1403
- Action Required
1404
- </h3>
1405
- <div className={styles.draftItineraryText}>
1406
- <b>Your proposed itinerary is ready.</b> Please review the times and stops above. Approve if it looks good, or request changes and tell us what you would like different.
1407
- {itineraryReviewError ? (
1408
- <p className={styles.paymentFormError} role="alert" style={{ marginTop: '0.75rem' }}>
1409
- {itineraryReviewError}
1410
- </p>
1411
- ) : null}
1412
- <div className={styles.draftItineraryReviewActions}>
1413
- <Button
1414
- type="button"
1415
- variant="primary"
1416
- hoverColor={ButtonHoverColor.Turquoise}
1417
- disabled={itineraryReviewSubmitting}
1418
- className={styles.pickupPrimaryButton}
1419
- onClick={handleApproveItinerary}
1420
- >
1421
- {itineraryReviewSubmitting ? 'Saving…' : 'Approve itinerary'}
1422
- </Button>
1423
- <button
1424
- type="button"
1425
- className={`${styles.pickupChangeLink} ${styles.draftItineraryRequestChangesButton}`}
1426
- onClick={() => {
1427
- setItineraryRequestChangesOpen((o) => !o);
1428
- setItineraryReviewError(null);
1429
- }}
1430
- disabled={itineraryReviewSubmitting}
1431
- aria-expanded={itineraryRequestChangesOpen}
1432
- >
1433
- <svg
1434
- className={styles.pickupEditIcon}
1435
- viewBox="0 0 24 24"
1436
- fill="none"
1437
- stroke="currentColor"
1438
- strokeWidth="2"
1439
- strokeLinecap="round"
1440
- strokeLinejoin="round"
1441
- aria-hidden
1442
- >
1443
- <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
1444
- <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
1445
- </svg>
1446
- {itineraryRequestChangesOpen ? 'Hide request form' : 'Request changes'}
1447
- </button>
1448
- </div>
1449
- {itineraryRequestChangesOpen ? (
1450
- <div className={styles.draftItineraryFeedbackFields}>
1451
- <label className={styles.draftItineraryFeedbackLabel} htmlFor="itinerary-change-feedback">
1452
- Your itinerary request
1453
- </label>
1454
- <textarea
1455
- id="itinerary-change-feedback"
1456
- value={requestChangesComment}
1457
- onChange={(e) => setRequestChangesComment(e.target.value)}
1458
- rows={4}
1459
- className={styles.draftItineraryTextarea}
1460
- placeholder="Describe what you would like different…"
1461
- />
1462
- <div className={styles.draftItineraryFeedbackSubmitRow}>
1463
- <Button
1464
- type="button"
1465
- variant="primary"
1466
- hoverColor={ButtonHoverColor.Turquoise}
1467
- disabled={itineraryReviewSubmitting}
1468
- className={styles.payOwingButton}
1469
- onClick={handleRequestItineraryChanges}
1470
- >
1471
- {itineraryReviewSubmitting ? 'Sending…' : 'Send request to change itinerary'}
1472
- </Button>
1473
- </div>
1474
- </div>
1475
- ) : null}
1476
- </div>
1477
- </div>
1478
- </div>
1479
- )}
1480
- </div>
1481
- )}
1482
-
1483
- {/* Pickup Details - always shown when itinerary has pickup/dropoff steps */}
1484
- {itineraryDisplay?.some(isPickupOrDropOffStep) && (
1485
- <div id="pickup-details" className={`${styles.section} ${styles.pickupDetailsSection}`}>
1486
- <h2 className={styles.sectionTitle}>
1487
- <svg className={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1488
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
1489
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
1490
- </svg>
1491
- Pickup Details
1492
- </h2>
1493
- {(() => {
1494
- const isGygBooking = Boolean(booking.gygBookingReference?.trim());
1495
- const hasPickupSelected = Boolean(booking.pickupLocationId || booking.travelerHotel);
1496
- if (hasPickupSelected) {
1497
- const pickupStep = booking.itineraryDisplay?.find((s) => s.stepType === 'pickup');
1498
- const pickupDisplayName =
1499
- booking.travelerHotel ||
1500
- pickupStep?.place ||
1501
- pickupStep?.label ||
1502
- (booking.pickupLocationId ? `Location ID: ${booking.pickupLocationId}` : null);
1503
- const pickupMapsQuery = booking.travelerHotel || pickupStep?.place || pickupStep?.label || String(booking.pickupLocationId ?? '');
1504
- return (
1505
- <>
1506
- <div className={styles.pickupLocationBlock}>
1507
- <p className={styles.pickupDetails}>
1508
- {pickupDisplayName}
1509
- </p>
1510
- <div className={styles.pickupLinksWrap}>
1511
- <a
1512
- href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(pickupMapsQuery)}`}
1513
- target="_blank"
1514
- rel="noopener noreferrer"
1515
- className={styles.pickupMapLink}
1516
- >
1517
- View on Google Maps
1518
- </a>
1519
- {!isGygBooking && (
1520
- <button
1521
- type="button"
1522
- onClick={openPickupDialog}
1523
- className={styles.pickupChangeLink}
1524
- aria-label="Change pickup location"
1525
- >
1526
- <svg className={styles.pickupEditIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1527
- <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
1528
- <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
1529
- </svg>
1530
- Change
1531
- </button>
1532
- )}
1533
- </div>
1534
- </div>
1535
- <div className={styles.finePrintSection}>
1536
- <p className={styles.finePrint}>
1537
- Please be ready at your pickup location 10 minutes before your pickup time. Your driver will send you a message to your preferred communication method to notify you when the shuttle is on its way.
1538
- </p>
1539
- </div>
1540
- </>
1541
- );
1542
- }
1543
- return (
1544
- <>
1545
- <p className={styles.pickupSelectPrompt}>
1546
- Please select your pickup location <strong>as soon as possible</strong> for your booking to be added to your driver&apos;s pickup manifest.
1547
- </p>
1548
- {isGygBooking ? (
1549
- <div className={styles.pickupButtonWrap}>
1550
- <a
1551
- href="https://www.getyourguide.com/contact#meeting-point-and-pickup"
1552
- target="_blank"
1553
- rel="noopener noreferrer"
1554
- className={styles.gygManageLink}
1555
- >
1556
- <svg className={styles.gygManageIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
1557
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
1558
- </svg>
1559
- Manage Your Booking With GetYourGuide
1560
- </a>
1561
- </div>
1562
- ) : (
1563
- <div className={styles.pickupButtonWrap}>
1564
- <button
1565
- type="button"
1566
- onClick={openPickupDialog}
1567
- className={styles.pickupPrimaryButton}
1568
- >
1569
- select pickup location
1570
- </button>
1571
- </div>
1572
- )}
1573
- <div className={styles.finePrintSection}>
1574
- <p className={styles.finePrint}>
1575
- If your pickup location is not selected at least 24 hours before your activity, your booking is at risk of being cancelled without refund. If you need help choosing the best pickup location for you, reach out to us via phone or email, we&apos;re happy to help!
1576
- </p>
1577
- </div>
1578
- </>
1579
- );
1580
- })()}
1581
- </div>
1582
- )}
1583
-
1584
- {/* Pickup location dialog */}
1585
- <PickupLocationDialog
1586
- isOpen={pickupDialogOpen}
1587
- onClose={closePickupDialog}
1588
- booking={booking}
1589
- onSuccess={(updated) => {
1590
- onBookingUpdate?.(updated);
1591
- closePickupDialog();
1592
- }}
1593
- />
1594
-
1595
- {/* Payment Summary */}
1596
- <div className={styles.section}>
1597
- <div className={styles.paymentSummaryHeader}>
1598
- <h2 className={styles.sectionTitle}>
1599
- <svg className={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1600
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
1601
- </svg>
1602
- Payment Summary
1603
- </h2>
1604
- {(() => {
1605
- const status = booking.payment?.status ?? '';
1606
- const plan = booking.payment?.plan;
1607
- const owesBalance = status === 'DEPOSIT_PAID' && (plan?.balanceAmount ?? 0) > 0;
1608
- const treatedAsFullyPaid = status === 'FULLY_PAID' || (status === 'DEPOSIT_PAID' && !owesBalance);
1609
- const label =
1610
- treatedAsFullyPaid
1611
- ? 'Fully paid'
1612
- : owesBalance
1613
- ? 'Balance owing'
1614
- : status === 'DEPOSIT_PAID'
1615
- ? 'Deposit paid'
1616
- : status === 'AWAITING_PAYMENT'
1617
- ? 'Payment due'
1618
- : null;
1619
- if (!label) return null;
1620
- const pillClass =
1621
- status === 'FULLY_PAID'
1622
- ? styles.paymentStatusPillPaid
1623
- : owesBalance
1624
- ? styles.paymentStatusPillOwing
1625
- : status === 'DEPOSIT_PAID'
1626
- ? styles.paymentStatusPillDeposit
1627
- : styles.paymentStatusPillAwaiting;
1628
- return (
1629
- <span className={`${styles.paymentStatusPill} ${pillClass}`}>
1630
- {label}
1631
- </span>
1632
- );
1633
- })()}
1634
- </div>
1635
-
1636
- <div className={styles.priceSummary}>
1637
- {(() => {
1638
- const hasChangeNewTotalLine = receipt.lineItems.some((item) => {
1639
- const label = (item.label ?? '').trim().toLowerCase();
1640
- return item.type === 'BOOKING_CHANGE' && label === 'new total';
1641
- });
1642
-
1643
- return (
1644
- <>
1645
- {receipt.lineItems.map((item, index) => (
1646
- <div
1647
- key={index}
1648
- className={
1649
- item.type === 'BOOKING_CHANGE' &&
1650
- (item.label ?? '').trim().toLowerCase() === 'new total'
1651
- ? `${styles.priceLine} ${styles.totalLine}`
1652
- : item.type === 'BOOKING_CHANGE' &&
1653
- (item.label ?? '').trim().toLowerCase() === 'previous total'
1654
- ? `${styles.priceLine} ${styles.changeDifferenceLine}`
1655
- : item.type === 'BOOKING_CHANGE' &&
1656
- (item.label ?? '').trim().toLowerCase().startsWith('new booking difference')
1657
- ? `${styles.priceLine} ${styles.changeDifferenceLine}`
1658
- : styles.priceLine
1659
- }
1660
- >
1661
- <span
1662
- className={
1663
- item.type === 'BOOKING_CHANGE' &&
1664
- (item.label ?? '').trim().toLowerCase() === 'new total'
1665
- ? styles.totalLabel
1666
- : item.type === 'BOOKING_CHANGE' &&
1667
- (item.label ?? '').trim().toLowerCase() === 'previous total'
1668
- ? styles.changeDifferenceLabel
1669
- : item.type === 'BOOKING_CHANGE' &&
1670
- (item.label ?? '').trim().toLowerCase().startsWith('new booking difference')
1671
- ? styles.changeDifferenceLabel
1672
- : styles.priceLabel
1673
- }
1674
- >
1675
- {item.label}
1676
- {item.quantity && item.quantity > 1 && !/\sx\s*\d+$/i.test(item.label) && ` × ${item.quantity}`}
1677
- </span>
1678
- <span
1679
- className={
1680
- item.type === 'BOOKING_CHANGE' &&
1681
- (item.label ?? '').trim().toLowerCase() === 'new total'
1682
- ? styles.totalAmount
1683
- : item.type === 'BOOKING_CHANGE' &&
1684
- (item.label ?? '').trim().toLowerCase() === 'previous total'
1685
- ? styles.changeDifferenceAmount
1686
- : item.type === 'BOOKING_CHANGE' &&
1687
- (item.label ?? '').trim().toLowerCase().startsWith('new booking difference')
1688
- ? styles.changeDifferenceAmount
1689
- : styles.priceAmount
1690
- }
1691
- >
1692
- {formatCurrencyAmount(item.amount, receipt.currency as Currency)}
1693
- </span>
1694
- </div>
1695
- ))}
1696
-
1697
- {receipt.taxAmount > 0 && (() => {
1698
- // When line items already include tax (e.g. GST as separate line for private shuttle),
1699
- // avoid duplicating Subtotal + Tax - the breakdown is already in the line items
1700
- const lineItemsIncludeTax = receipt.lineItems.some((item) => {
1701
- const label = (item.label ?? '').toLowerCase();
1702
- const type = (item.type ?? '').toLowerCase();
1703
- return /\b(gst|tax|hst|pst|vat)\b/.test(label) || /\b(gst|tax|hst|pst|vat)\b/.test(type);
1704
- });
1705
- if (lineItemsIncludeTax) return null;
1706
- return (
1707
- <>
1708
- <div className={styles.priceLine}>
1709
- <span className={styles.priceLabel}>Subtotal</span>
1710
- <span className={styles.priceAmount}>
1711
- {formatCurrencyAmount(receipt.subtotalBeforeTax, receipt.currency as Currency)}
1712
- </span>
1713
- </div>
1714
- <div className={styles.priceLine}>
1715
- <span className={styles.priceLabel}>Tax</span>
1716
- <span className={styles.priceAmount}>
1717
- {formatCurrencyAmount(receipt.taxAmount, receipt.currency as Currency)}
1718
- </span>
1719
- </div>
1720
- </>
1721
- );
1722
- })()}
1723
-
1724
- {!hasChangeNewTotalLine && (
1725
- <div className={`${styles.priceLine} ${styles.totalLine}`}>
1726
- <span className={styles.totalLabel}>Total</span>
1727
- <span className={styles.totalAmount}>
1728
- {formatCurrencyAmount(receipt.totalAmount, receipt.currency as Currency)}
1729
- </span>
1730
- </div>
1731
- )}
1732
- </>
1733
- );
1734
- })()}
1735
- {booking.payment?.status === 'DEPOSIT_PAID' && (booking.payment?.plan?.balanceAmount ?? 0) > 0 && (booking.payment?.plan?.depositAmount ?? 0) > 0 && (
1736
- <div className={styles.priceLine}>
1737
- <span className={styles.priceLabel}>Deposit paid</span>
1738
- <span className={styles.priceAmount}>
1739
- -{formatCurrencyAmount(booking.payment!.plan!.depositAmount!, receipt.currency as Currency)}
1740
- </span>
1741
- </div>
1742
- )}
1743
- </div>
1744
-
1745
- {(() => {
1746
- const status = booking.payment?.status ?? '';
1747
- const plan = booking.payment?.plan;
1748
- const isAwaitingPayment = status === 'AWAITING_PAYMENT';
1749
- const owesBalance = status === 'DEPOSIT_PAID' && (plan?.balanceAmount ?? 0) > 0;
1750
- const depositAmount = plan?.depositAmount ?? 0;
1751
- const amountOwing = isAwaitingPayment
1752
- ? receipt.totalAmount
1753
- : owesBalance
1754
- ? (plan?.balanceAmount ?? 0)
1755
- : 0;
1756
- const showPayButton = (isAwaitingPayment || owesBalance) && amountOwing > 0;
1757
-
1758
- if (!showPayButton) return null;
1759
-
1760
- return (
1761
- <div className={styles.payOwingSection}>
1762
- <div className={styles.payOwingRow}>
1763
- <span className={styles.payOwingLabel}>
1764
- {isAwaitingPayment ? 'Amount owing' : 'Balance due'}
1765
- </span>
1766
- <span className={styles.payOwingAmount}>
1767
- {formatCurrencyAmount(amountOwing, receipt.currency as Currency)}
1768
- </span>
1769
- </div>
1770
- {paymentError && (
1771
- <p className={styles.payOwingError} role="alert">{paymentError}</p>
1772
- )}
1773
- <div className={styles.payOwingButtons}>
1774
- {isAwaitingPayment ? (
1775
- <>
1776
- {depositAmount > 0 && (
1777
- <Button
1778
- type="button"
1779
- variant="secondary"
1780
- hoverColor={ButtonHoverColor.Orange}
1781
- disabled={paymentLoading !== null || !ENV.STRIPE_PUBLISHABLE_KEY}
1782
- className={`${styles.payOwingButton} ${styles.payOwingButtonOutline}`}
1783
- onClick={handlePayDeposit}
1784
- >
1785
- {paymentLoading === 'deposit' ? 'Loading...' : `Pay deposit (${formatCurrencyAmount(depositAmount, receipt.currency as Currency)})`}
1786
- </Button>
1787
- )}
1788
- <Button
1789
- type="button"
1790
- variant="primary"
1791
- hoverColor={ButtonHoverColor.Turquoise}
1792
- disabled={paymentLoading !== null || !ENV.STRIPE_PUBLISHABLE_KEY}
1793
- className={styles.payOwingButton}
1794
- onClick={handlePayFull}
1795
- >
1796
- {paymentLoading === 'full' ? 'Loading...' : `Pay full balance (${formatCurrencyAmount(receipt.totalAmount, receipt.currency as Currency)})`}
1797
- </Button>
1798
- {(booking.payment?.balanceChargeScheduledAt ?? plan?.chargeDate) && (
1799
- <p className={styles.payOwingDisclaimer}>
1800
- Any remaining balance will be automatically charged to your card on file on{' '}
1801
- <b>{formatChargeDate(booking.payment?.balanceChargeScheduledAt ?? plan?.chargeDate ?? null)}</b>.
1802
- </p>
1803
- )}
1804
- </>
1805
- ) : (
1806
- <>
1807
- <Button
1808
- type="button"
1809
- variant="primary"
1810
- hoverColor={ButtonHoverColor.Turquoise}
1811
- disabled={paymentLoading !== null || !ENV.STRIPE_PUBLISHABLE_KEY}
1812
- className={styles.payOwingButton}
1813
- onClick={handlePayBalance}
1814
- >
1815
- {paymentLoading === 'balance' ? 'Loading...' : 'Pay balance'}
1816
- </Button>
1817
- {(booking.payment?.balanceChargeScheduledAt ?? plan?.chargeDate) && (
1818
- <p className={styles.payOwingDisclaimer}>
1819
- Any remaining balance will be automatically charged to your card on file on{' '}
1820
- <b>{formatChargeDate(booking.payment?.balanceChargeScheduledAt ?? plan?.chargeDate ?? null)}</b>.
1821
- </p>
1822
- )}
1823
- </>
1824
- )}
1825
- </div>
1826
- </div>
1827
- );
1828
- })()}
1829
- </div>
1830
-
1831
- {/* Change Booking */}
1832
- {shouldShowChangeBookingSection && (
1833
- <div className={styles.section}>
1834
- <h2 className={styles.sectionTitle}>
1835
- <svg className={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1836
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 2v6h6" />
1837
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 22v-6h-6" />
1838
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 00-15-6.7L3 8" />
1839
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12a9 9 0 0015 6.7l3-2.7" />
1840
- </svg>
1841
- Change Booking
1842
- </h2>
1843
- <p className={styles.changeBookingCopy}>
1844
- You can <b>change your experience, date, time, ticket quantity, pickup, and selected options</b>. Your existing promo and cancellation policy stay the same.
1845
- Additional payment may be required when there is a positive difference between the new and old booking total.
1846
- </p>
1847
- {booking.lastChangeSummary?.changedAt && (
1848
- <p className={styles.changeBookingMeta}>
1849
- Last change: {formatDateTime(booking.lastChangeSummary.changedAt)}
1850
- </p>
1851
- )}
1852
- {booking.lastChangeSummary?.amountPaid != null && booking.lastChangeSummary.amountPaid > 0 && (
1853
- <p className={styles.changeBookingMeta}>
1854
- Additional amount paid: {formatCurrencyAmount(booking.lastChangeSummary.amountPaid, (booking.lastChangeSummary.currency || booking.receipt.currency) as Currency)}
1855
- </p>
1856
- )}
1857
- <div className={styles.changeBookingActions}>
1858
- <button
1859
- type="button"
1860
- onClick={() => {
1861
- if (booking.canChange === false) {
1862
- setChangeFlowMessage(
1863
- booking.changeBlockedReason?.trim() ||
1864
- 'This booking is not eligible for self-serve changes.'
1865
- );
1866
- return;
1867
- }
1868
- setChangeFlowMessage(null);
1869
- setChangeDialogOpen(true);
1870
- }}
1871
- className={styles.contactSaveBtn}
1872
- disabled={isCancelled}
1873
- >
1874
- Change booking
1875
- </button>
1876
- </div>
1877
- {(booking.changeByDateTime || cancellationPolicySnapshot?.changeWindowHoursBefore != null) && (
1878
- <p className={styles.changeBookingMeta}>
1879
- Change deadline:{' '}
1880
- <strong>
1881
- {booking.changeByDateTime
1882
- ? formatDateTime(booking.changeByDateTime)
1883
- : formatCancelByDate(effectivePolicyDateTime, cancellationPolicySnapshot?.changeWindowHoursBefore ?? 0)}
1884
- </strong>.
1885
- </p>
1886
- )}
1887
- {policyAnchorIsEarlierThanTrip(booking.policyAnchorDateTime, booking.dateTime) && booking.policyAnchorDateTime && (
1888
- <p className={styles.changeBookingMeta}>
1889
- Refund and self-serve change rules use your original trip time ({formatDateTime(booking.policyAnchorDateTime)}), not the
1890
- rescheduled departure.
1891
- </p>
1892
- )}
1893
- {changeFlowMessage && <p className={styles.changeBookingBlocked}>{changeFlowMessage}</p>}
1894
- </div>
1895
- )}
1896
- <ChangeBookingDialog
1897
- isOpen={changeDialogOpen}
1898
- booking={booking}
1899
- onChangeCompleted={(preview: ChangeFlowSelectionPreview | null) => {
1900
- pendingChangeSuccessPreviewRef.current = preview;
1901
- setChangeFlowMessage(null);
1902
- }}
1903
- onClose={async () => {
1904
- setChangeDialogOpen(false);
1905
- await onRefetch?.();
1906
- const pending = pendingChangeSuccessPreviewRef.current;
1907
- pendingChangeSuccessPreviewRef.current = undefined;
1908
- if (pending !== undefined) {
1909
- setChangeFlowSuccessMessage(manageBookingChangeSuccessMessage(pending));
1910
- }
1911
- if (typeof window !== 'undefined') {
1912
- window.scrollTo({ top: 0, behavior: 'smooth' });
1913
- }
1914
- }}
1915
- />
1916
-
1917
- {/* Cancellation Policy */}
1918
- {cancellationPolicySnapshot && (() => {
1919
- const isGygBooking = Boolean(booking.gygBookingReference?.trim());
1920
- if (isGygBooking) {
1921
- return (
1922
- <div className={styles.section}>
1923
- <h2 className={styles.sectionTitle}>
1924
- <svg className={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1925
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
1926
- </svg>
1927
- Cancellation Policy
1928
- </h2>
1929
- <p className={styles.policyLabel}>
1930
- Your booking was made with a third party booking platform,{' '}
1931
- <a href="#getyourguide" className={styles.policyGygLink}>GetYourGuide</a>
1932
- . Your booking is subject to the cancellation policy of GetYourGuide and all cancellations and refunds can only be processed by GetYourGuide.
1933
- </p>
1934
- <a
1935
- href="https://www.getyourguide.com/contact#booking-management"
1936
- target="_blank"
1937
- rel="noopener noreferrer"
1938
- className={styles.gygManageLink}
1939
- >
1940
- <svg className={styles.gygManageIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
1941
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
1942
- </svg>
1943
- Manage Your Booking With GetYourGuide
1944
- </a>
1945
- </div>
1946
- );
1947
- }
1948
- const tiers = (cancellationPolicySnapshot.refundTiers ?? cancellationPolicySnapshot.tiers?.map(t => ({ hoursBefore: t.hoursBeforeBooking, refundPercent: t.refundPercentage })) ?? [])
1949
- .slice()
1950
- .sort((a, b) => b.hoursBefore - a.hoursBefore);
1951
- const hasTiers = tiers.length > 0;
1952
- return (
1953
- <div className={styles.section}>
1954
- <h2 className={styles.sectionTitle}>
1955
- <svg className={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
1956
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
1957
- </svg>
1958
- Cancellation Policy
1959
- </h2>
1960
- {!hasTiers && (
1961
- <p className={styles.policyLabel}>
1962
- No cancellation, no refunds.
1963
- </p>
1964
- )}
1965
- {hasTiers && (
1966
- <div className={styles.policyCancelBy}>
1967
- <p className={styles.policyCancelByHeading}>Cancel by</p>
1968
- <div className={styles.policyTierList}>
1969
- {tiers.map((tier, index) => {
1970
- const cancelBy = formatCancelByDate(effectivePolicyDateTime, tier.hoursBefore);
1971
- const hasDeposit = (booking.payment?.plan?.depositAmount ?? 0) > 0;
1972
- const isDraftItineraryConfirmed =
1973
- booking.productType === 'PRIVATE_SHUTTLE'
1974
- ? effectiveItineraryReviewStatus(booking.draftItinerary) === 'APPROVED'
1975
- : Boolean(booking.draftItinerary?.approvedAt?.trim());
1976
- const isPrivateShuttle = booking.productType === 'PRIVATE_SHUTTLE';
1977
- // Only mention "excluding deposit" for private shuttle bookings with a deposit.
1978
- const excludingDepositSuffix = isPrivateShuttle && hasDeposit
1979
- ? (isDraftItineraryConfirmed
1980
- ? <>, <strong>excluding deposit</strong>.</>
1981
- : <>, <strong>excluding deposit (after itinerary is approved).</strong></>)
1982
- : '.';
1983
- const refundBase = tier.refundPercent === 100
1984
- ? 'Full refund: Get back 100% of what you paid'
1985
- : `Partial refund: Get back ${tier.refundPercent}% of what you paid`;
1986
- return (
1987
- <div key={index} className={styles.policyTierRow}>
1988
- <div className={styles.policyTierDeadline}>
1989
- {cancelBy || `${tier.hoursBefore} hours before`}
1990
- </div>
1991
- <div className={styles.policyTierRefund}>
1992
- {refundBase}{excludingDepositSuffix}
1993
- </div>
1994
- </div>
1995
- );
1996
- })}
1997
- </div>
1998
- </div>
1999
- )}
2000
- {cancellationPolicySnapshot.changeWindowHoursBefore != null && effectivePolicyDateTime && (
2001
- <p className={styles.policyChangeWindow}>
2002
- Make changes to your booking (date, time, return, quantities, etc.) by <strong>{formatCancelByDate(effectivePolicyDateTime, cancellationPolicySnapshot.changeWindowHoursBefore)}</strong>.
2003
- </p>
2004
- )}
2005
- {cancellationInfo?.canCancel && booking.productType !== 'PRIVATE_SHUTTLE' && (
2006
- <div className={styles.cancelBookingSection}>
2007
- <button
2008
- type="button"
2009
- onClick={openCancelConfirmDialog}
2010
- disabled={contactSaving}
2011
- className={styles.contactSaveBtn}
2012
- >
2013
- Cancel booking
2014
- </button>
2015
- <p className={styles.cancelBookingText} dangerouslySetInnerHTML={{ __html: cancellationInfo.refundPercent > 0
2016
- ? `Cancel for a <b>${cancellationInfo.refundPercent}% refund</b> of <b>${formatCurrencyAmount(cancellationInfo.refundAmount, cancellationInfo.currency as Currency)}</b>.`
2017
- : 'Cancel booking. <strong>Note:</strong> This booking is not eligible for a refund.'}} />
2018
- {cancelError && !cancelConfirmOpen && <p className={styles.cancelBookingError} role="alert">{cancelError}</p>}
2019
- </div>
2020
- )}
2021
- </div>
2022
- );
2023
- })()}
2024
-
2025
- {/* Contact & Communication - inline editable */}
2026
- <div className={styles.section} id="contact-details">
2027
- <h2 className={styles.sectionTitle}>
2028
- <svg className={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
2029
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
2030
- </svg>
2031
- Contact Information
2032
- </h2>
2033
- <div className={styles.contactForm}>
2034
- <div className={styles.contactFieldGroup}>
2035
- <label htmlFor="contact-email" className={styles.contactFieldLabel}>Email</label>
2036
- <input
2037
- id="contact-email"
2038
- type="email"
2039
- value={contactEmail}
2040
- onChange={(e) => setContactEmail(e.target.value)}
2041
- className={styles.contactInput}
2042
- placeholder="e.g. you@example.com"
2043
- />
2044
- </div>
2045
- <div className={styles.contactFieldGroup}>
2046
- <label htmlFor="contact-phone" className={styles.contactFieldLabel}>Phone number</label>
2047
- <PhoneInputWithCountryComponent
2048
- id="contact-phone"
2049
- value={contactPhone}
2050
- onChange={(value) => setContactPhone(value ?? '')}
2051
- placeholder="e.g. +1 403 555 0123"
2052
- />
2053
- </div>
2054
- <div className={styles.contactFieldGroup}>
2055
- <label htmlFor="contact-whatsapp" className={styles.contactFieldLabel}>
2056
- <WhatsAppIconSmall className={styles.whatsappIconSmall} />
2057
- WhatsApp number
2058
- </label>
2059
- <p className={styles.contactHint}>Different from phone? Add it here.</p>
2060
- <PhoneInputWithCountryComponent
2061
- id="contact-whatsapp"
2062
- value={contactWhatsApp}
2063
- onChange={(value) => setContactWhatsApp(value ?? '')}
2064
- placeholder="e.g. +1 403 555 0123"
2065
- />
2066
- </div>
2067
- <div className={styles.contactFieldGroup}>
2068
- <span className={styles.contactFieldLabel}>Preferred communication method</span>
2069
- <div className={styles.contactCheckboxGroup}>
2070
- {COMMUNICATION_OPTIONS.map((opt) => (
2071
- <label key={opt.value} className={styles.contactCheckboxLabel}>
2072
- <input
2073
- type="checkbox"
2074
- checked={contactCommPref.includes(opt.value)}
2075
- onChange={() => toggleCommPref(opt.value)}
2076
- className={styles.contactCheckbox}
2077
- />
2078
- {opt.value === 'WHATSAPP' ? (
2079
- <span className={styles.contactCheckboxText}>
2080
- <WhatsAppIconSmall className={styles.whatsappIconSmall} />
2081
- {opt.label}
2082
- </span>
2083
- ) : (
2084
- opt.label
2085
- )}
2086
- </label>
2087
- ))}
2088
- </div>
2089
- </div>
2090
- {contactError && <p className={styles.contactError}>{contactError}</p>}
2091
- <button
2092
- type="button"
2093
- onClick={handleSaveContact}
2094
- disabled={contactSaving}
2095
- className={styles.contactSaveBtn}
2096
- >
2097
- {contactSaving ? 'Saving…' : 'save contact details'}
2098
- </button>
2099
- </div>
2100
- </div>
2101
-
2102
- {dependentAddOnUpsellPlacement === 'bottom' ? (
2103
- <PostBookingDependentAddOnUpsell booking={booking} enabled />
2104
- ) : null}
2105
- {bookingRefreshPending && !showCancelOverlay && (
2106
- <div
2107
- className={styles.bookingRefreshOverlay}
2108
- role="status"
2109
- aria-live="polite"
2110
- aria-busy="true"
2111
- aria-label="Updating booking"
2112
- >
2113
- <div className={styles.cancelOverlaySpinner} aria-hidden />
2114
- <p className={styles.cancelOverlayText}>Updating your booking…</p>
2115
- </div>
2116
- )}
2117
-
2118
- {/* Full-screen overlay while cancelling (hides scroll/layout shift) */}
2119
- {showCancelOverlay && (
2120
- <div className={styles.cancelOverlay} role="status" aria-live="polite" aria-label="Cancelling booking">
2121
- <div className={styles.cancelOverlaySpinner} aria-hidden />
2122
- <p className={styles.cancelOverlayText}>Cancelling booking…</p>
2123
- </div>
2124
- )}
2125
-
2126
- {/* Cancel booking confirmation dialog */}
2127
- {cancelConfirmOpen && (
2128
- <div className={styles.paymentModalOverlay} role="dialog" aria-modal="true" aria-labelledby="cancel-confirm-title">
2129
- <div className={styles.cancelConfirmModal}>
2130
- <h3 id="cancel-confirm-title" className={styles.cancelConfirmTitle}>Cancel booking?</h3>
2131
- <p className={styles.cancelConfirmMessage}>
2132
- This action cannot be undone. Are you sure you want to cancel your booking?
2133
- </p>
2134
- {cancelError && <p className={styles.cancelBookingError} role="alert">{cancelError}</p>}
2135
- <div className={styles.cancelConfirmActions}>
2136
- <Button variant="outline" className={styles.cancelBtn} onClick={closeCancelConfirmDialog}>
2137
- Cancel
2138
- </Button>
2139
- <Button
2140
- variant="primary"
2141
- hoverColor={ButtonHoverColor.Turquoise}
2142
- onClick={handleCancelBooking}
2143
- disabled={cancelling}
2144
- >
2145
- {cancelling ? 'Cancelling…' : 'Yes, cancel booking'}
2146
- </Button>
2147
- </div>
2148
- </div>
2149
- </div>
2150
- )}
2151
-
2152
- {/* Stripe payment modal */}
2153
- {showPaymentModal && paymentClientSecret && stripePromise && (
2154
- <div className={styles.paymentModalOverlay}>
2155
- <div className={styles.paymentModal}>
2156
- <div className={styles.paymentModalHeader}>
2157
- <h3 className={styles.paymentModalTitle}>{paymentModalLabel}</h3>
2158
- <button
2159
- type="button"
2160
- onClick={closePaymentModal}
2161
- className={styles.paymentModalClose}
2162
- aria-label="Close"
2163
- >
2164
- <svg className={styles.paymentModalCloseIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
2165
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
2166
- </svg>
2167
- </button>
2168
- </div>
2169
- <div className={styles.paymentModalSummary}>
2170
- {receipt.lineItems.map((item, index) => (
2171
- <div key={index} className={styles.paymentModalLine}>
2172
- <span className={styles.paymentModalLineLabel}>
2173
- {item.label}
2174
- {item.quantity && item.quantity > 1 && !/\sx\s*\d+$/i.test(item.label) && ` × ${item.quantity}`}
2175
- </span>
2176
- <span className={styles.paymentModalLineAmount}>
2177
- {formatCurrencyAmount(item.amount, receipt.currency as Currency)}
2178
- </span>
2179
- </div>
2180
- ))}
2181
- {receipt.taxAmount > 0 && (() => {
2182
- const lineItemsIncludeTax = receipt.lineItems.some((item) => {
2183
- const label = (item.label ?? '').toLowerCase();
2184
- const type = (item.type ?? '').toLowerCase();
2185
- return /\b(gst|tax|hst|pst|vat)\b/.test(label) || /\b(gst|tax|hst|pst|vat)\b/.test(type);
2186
- });
2187
- if (lineItemsIncludeTax) return null;
2188
- return (
2189
- <>
2190
- <div className={styles.paymentModalLine}>
2191
- <span className={styles.paymentModalLineLabel}>Subtotal</span>
2192
- <span className={styles.paymentModalLineAmount}>
2193
- {formatCurrencyAmount(receipt.subtotalBeforeTax, receipt.currency as Currency)}
2194
- </span>
2195
- </div>
2196
- <div className={styles.paymentModalLine}>
2197
- <span className={styles.paymentModalLineLabel}>Tax</span>
2198
- <span className={styles.paymentModalLineAmount}>
2199
- {formatCurrencyAmount(receipt.taxAmount, receipt.currency as Currency)}
2200
- </span>
2201
- </div>
2202
- </>
2203
- );
2204
- })()}
2205
- <div className={styles.paymentModalTotal}>
2206
- <span className={styles.paymentModalTotalLabel}>Total</span>
2207
- <span className={styles.paymentModalTotalAmount}>
2208
- {formatCurrencyAmount(receipt.totalAmount, receipt.currency as Currency)}
2209
- </span>
2210
- </div>
2211
- {paymentModalLabel === 'Pay balance' && (booking.payment?.plan?.depositAmount ?? 0) > 0 && (
2212
- <>
2213
- <div className={styles.paymentModalLine}>
2214
- <span className={styles.paymentModalLineLabel}>Deposit paid</span>
2215
- <span className={styles.paymentModalLineAmount}>
2216
- -{formatCurrencyAmount(booking.payment!.plan!.depositAmount!, receipt.currency as Currency)}
2217
- </span>
2218
- </div>
2219
- <div className={styles.paymentModalTotal}>
2220
- <span className={styles.paymentModalTotalLabel}>Balance due</span>
2221
- <span className={styles.paymentModalTotalAmount}>
2222
- {formatCurrencyAmount(booking.payment!.plan!.balanceAmount!, receipt.currency as Currency)}
2223
- </span>
2224
- </div>
2225
- </>
2226
- )}
2227
- {paymentModalLabel === 'Pay deposit' && (booking.payment?.plan?.depositAmount ?? 0) > 0 && (
2228
- <div className={styles.paymentModalDepositDue}>
2229
- <span className={styles.paymentModalDepositDueLabel}>Deposit due today</span>
2230
- <span className={styles.paymentModalDepositDueAmount}>
2231
- {formatCurrencyAmount(booking.payment!.plan!.depositAmount!, receipt.currency as Currency)}
2232
- </span>
2233
- </div>
2234
- )}
2235
- </div>
2236
- <div className={styles.paymentModalBody}>
2237
- <Elements
2238
- stripe={stripePromise}
2239
- options={{
2240
- clientSecret: paymentClientSecret,
2241
- appearance: { theme: 'stripe' as const, variables: { colorPrimary: '#059669', borderRadius: '8px' } },
2242
- }}
2243
- >
2244
- <PaymentForm
2245
- successUrl={`${typeof window !== 'undefined' ? window.location.origin : ''}/manage-booking?ref=${encodeURIComponent(formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference)}&lastName=${encodeURIComponent(booking.customer?.lastName || '')}&payment=balance_success`}
2246
- onClose={closePaymentModal}
2247
- payLabel={paymentModalLabel}
2248
- amount={
2249
- paymentModalLabel === 'Pay deposit' && (booking.payment?.plan?.depositAmount ?? 0) > 0
2250
- ? (booking.payment!.plan!.depositAmount ?? 0)
2251
- : paymentModalLabel === 'Pay full balance'
2252
- ? receipt.totalAmount
2253
- : (booking.payment?.plan?.balanceAmount ?? 0)
2254
- }
2255
- currency={receipt.currency as Currency}
2256
- />
2257
- </Elements>
2258
- </div>
2259
- </div>
2260
- </div>
2261
- )}
2262
- </div>
2263
- );
2264
- }