@ticketboothapp/booking 0.1.11 → 0.1.12

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 (252) hide show
  1. package/package.json +1 -1
  2. package/src/colours.css +23 -0
  3. package/src/components/BookingDetails.module.css +1591 -0
  4. package/src/components/BookingDetails.tsx +2072 -354
  5. package/src/components/BookingWidget.tsx +28 -248
  6. package/src/components/JobApplicationDialog.module.css +440 -0
  7. package/src/components/JobApplicationDialog.tsx +620 -0
  8. package/src/components/ManageBookingView.tsx +28 -36
  9. package/src/components/PhoneInputWithCountry.module.css +131 -0
  10. package/src/components/PhoneInputWithCountry.tsx +44 -0
  11. package/src/components/PickupLocationDialog.module.css +360 -0
  12. package/src/components/PickupLocationDialog.tsx +357 -0
  13. package/src/components/PickupLocationMap.tsx +110 -0
  14. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  15. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  16. package/src/components/accordion.css +27 -0
  17. package/src/components/accordion.tsx +29 -0
  18. package/src/components/analytics/AnalyticsConsentRestore.tsx +19 -0
  19. package/src/components/analytics/AnalyticsScripts.tsx +106 -0
  20. package/src/components/analytics/CookieConsentBanner.css +86 -0
  21. package/src/components/analytics/CookieConsentBanner.tsx +102 -0
  22. package/src/components/booking/AddOnsSection.module.css +10 -0
  23. package/src/components/booking/AddOnsSection.tsx +184 -0
  24. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  25. package/src/components/booking/BookingDialog.module.css +643 -0
  26. package/src/components/booking/BookingDialog.tsx +356 -0
  27. package/src/components/booking/BookingFlow.tsx +4385 -0
  28. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  29. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  30. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  31. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  32. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  33. package/src/components/booking/BookingProductGrid.module.css +359 -0
  34. package/src/components/booking/BookingProductGrid.tsx +497 -0
  35. package/src/components/booking/Calendar.module.css +616 -0
  36. package/src/components/{Calendar.tsx → booking/Calendar.tsx} +464 -247
  37. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  38. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  39. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  40. package/src/components/booking/CheckoutForm.module.css +244 -0
  41. package/src/components/booking/CheckoutForm.tsx +364 -0
  42. package/src/components/{CheckoutModal.tsx → booking/CheckoutModal.tsx} +176 -19
  43. package/src/components/booking/DapFlowCollage.tsx +88 -0
  44. package/src/components/booking/DapTourDescription.tsx +35 -0
  45. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  46. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  47. package/src/components/booking/InfoTooltip.tsx +108 -0
  48. package/src/components/booking/ItineraryBox.module.css +258 -0
  49. package/src/components/booking/ItineraryBox.tsx +550 -0
  50. package/src/components/{ItineraryBuilder.tsx → booking/ItineraryBuilder.tsx} +1 -2
  51. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  52. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  53. package/src/components/{MealDrinkAddOnSelector.tsx → booking/MealDrinkAddOnSelector.tsx} +21 -13
  54. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  55. package/src/components/{PickupLocationSelector.tsx → booking/PickupLocationSelector.tsx} +315 -290
  56. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  57. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  58. package/src/components/{PriceBreakdown.tsx → booking/PriceBreakdown.tsx} +3 -3
  59. package/src/components/{PriceSummary.tsx → booking/PriceSummary.tsx} +51 -28
  60. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  61. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  62. package/src/components/booking/PromoCodeInput.module.css +166 -0
  63. package/src/components/booking/PromoCodeInput.tsx +99 -0
  64. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  65. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  66. package/src/components/{TermsAcceptance.tsx → booking/TermsAcceptance.tsx} +9 -8
  67. package/src/components/booking/TicketSelector.module.css +164 -0
  68. package/src/components/booking/TicketSelector.tsx +199 -0
  69. package/src/components/booking/TourDescription.module.css +304 -0
  70. package/src/components/booking/TourDescription.tsx +273 -0
  71. package/src/components/booking/booking-flow-ui.ts +15 -1
  72. package/src/components/booking/booking-flow.css +944 -0
  73. package/src/components/bottom-sheet.module.css +78 -0
  74. package/src/components/bottom-sheet.tsx +60 -0
  75. package/src/components/breadcrumb.module.css +40 -0
  76. package/src/components/breadcrumb.tsx +36 -0
  77. package/src/components/button.css +245 -0
  78. package/src/components/button.tsx +152 -0
  79. package/src/components/client-bottom-sheet.tsx +14 -0
  80. package/src/components/colorable-svg.tsx +29 -0
  81. package/src/components/conditional-footer.tsx +27 -0
  82. package/src/components/contact-us.module.css +147 -0
  83. package/src/components/contact-us.tsx +49 -0
  84. package/src/components/email-signup.css +151 -0
  85. package/src/components/email-signup.tsx +63 -0
  86. package/src/components/faq-wrapper.module.css +47 -0
  87. package/src/components/faq-wrapper.tsx +15 -0
  88. package/src/components/footer.css +187 -0
  89. package/src/components/footer.tsx +143 -0
  90. package/src/components/global-simple-modal.tsx +33 -0
  91. package/src/components/google-review-summary.module.css +77 -0
  92. package/src/components/google-review-summary.tsx +50 -0
  93. package/src/components/hero-image.css +13 -0
  94. package/src/components/hero-image.tsx +44 -0
  95. package/src/components/image.css +29 -0
  96. package/src/components/image.tsx +113 -0
  97. package/src/components/language-aware-link.tsx +72 -0
  98. package/src/components/language-switcher.module.css +124 -0
  99. package/src/components/language-switcher.tsx +75 -0
  100. package/src/components/map-section.css +59 -0
  101. package/src/components/map-section.tsx +63 -0
  102. package/src/components/navbar.module.css +152 -0
  103. package/src/components/navbar.tsx +125 -0
  104. package/src/components/parallax-provider.tsx +11 -0
  105. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  106. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  107. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +19 -35
  108. package/src/components/product-tag.module.css +30 -0
  109. package/src/components/product-tag.tsx +34 -0
  110. package/src/components/product-theme-pages/best-option.module.css +70 -0
  111. package/src/components/product-theme-pages/best-option.tsx +35 -0
  112. package/src/components/product-theme-pages/extended-tour-options.module.css +22 -0
  113. package/src/components/product-theme-pages/extended-tour-options.tsx +11 -0
  114. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  115. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  116. package/src/components/product-theme-pages/photo-gallery.tsx +90 -0
  117. package/src/components/product-theme-pages/product-theme-page-layout.module.css +13 -0
  118. package/src/components/product-theme-pages/product-theme-page-layout.tsx +67 -0
  119. package/src/components/product-theme-pages/top-of-fold.module.css +179 -0
  120. package/src/components/product-theme-pages/top-of-fold.tsx +80 -0
  121. package/src/components/product-tile/image-only-product-tile-desktop.module.css +106 -0
  122. package/src/components/product-tile/image-only-product-tile-desktop.tsx +56 -0
  123. package/src/components/product-tile/image-only-product-tile-mobile.module.css +122 -0
  124. package/src/components/product-tile/image-only-product-tile-mobile.tsx +89 -0
  125. package/src/components/product-tile/image-only-product-tile.tsx +44 -0
  126. package/src/components/product-tile/product-tile-card.module.css +84 -0
  127. package/src/components/product-tile/product-tile-card.tsx +61 -0
  128. package/src/components/review-highlights-section.css +85 -0
  129. package/src/components/review-highlights-section.tsx +127 -0
  130. package/src/components/season-closure-overlay.module.css +99 -0
  131. package/src/components/season-closure-overlay.tsx +98 -0
  132. package/src/components/simple-modal.tsx +69 -0
  133. package/src/components/simple-top-of-fold.module.css +76 -0
  134. package/src/components/simple-top-of-fold.tsx +34 -0
  135. package/src/components/spacer.css +41 -0
  136. package/src/components/spacer.tsx +23 -0
  137. package/src/components/star-rating.module.css +74 -0
  138. package/src/components/star-rating.tsx +48 -0
  139. package/src/components/terms/TermsContent.tsx +178 -0
  140. package/src/components/title-subtitle.module.css +10 -0
  141. package/src/components/title-subtitle.tsx +30 -0
  142. package/src/components/translatable-reviews.tsx +75 -0
  143. package/src/components/value-pill.module.css +59 -0
  144. package/src/components/value-pill.tsx +46 -0
  145. package/src/components/value-props.css +185 -0
  146. package/src/components/value-props.tsx +88 -0
  147. package/src/constants/booking-guide-quiz.ts +64 -0
  148. package/src/constants/contact-info.ts +2 -0
  149. package/src/constants/faq.ts +44 -0
  150. package/src/constants/images.ts +556 -0
  151. package/src/constants/json-ld/faq-json-ld.tsx +170 -0
  152. package/src/constants/json-ld/homepage-json-ld.tsx +138 -0
  153. package/src/constants/json-ld/job-posting-json-ld.tsx +92 -0
  154. package/src/constants/json-ld/organization-json-ld.tsx +62 -0
  155. package/src/constants/json-ld/page-json-ld.tsx +6 -0
  156. package/src/constants/json-ld/product-json-ld.tsx +154 -0
  157. package/src/constants/json-ld/review-json-ld.tsx +377 -0
  158. package/src/constants/navigation-links/footer-links.ts +48 -0
  159. package/src/constants/navigation-links/nav-bar-links.ts +41 -0
  160. package/src/constants/navigation-links/navigation-link.ts +6 -0
  161. package/src/constants/pill-values.ts +210 -0
  162. package/src/constants/products.ts +155 -0
  163. package/src/constants/quiz-recommendations.ts +506 -0
  164. package/src/constants/reviews.ts +75 -0
  165. package/src/constants/staff.ts +197 -0
  166. package/src/constants/value-props.ts +58 -0
  167. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  168. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  169. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  170. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  171. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  172. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  173. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  174. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  175. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  176. package/src/data/product-descriptions/private-tour.en.json +80 -0
  177. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  178. package/src/data/products-config.json +101 -0
  179. package/src/hooks/use-bottom-sheet.tsx +15 -0
  180. package/src/hooks/use-simple-modal.tsx +27 -0
  181. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  182. package/src/hooks/useEmailSubscription.tsx +103 -0
  183. package/src/hooks/useEmbeddedInIframe.ts +16 -0
  184. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  185. package/src/hooks/useQuiz.tsx +210 -0
  186. package/src/index.ts +27 -2
  187. package/src/lib/analytics.ts +197 -0
  188. package/src/lib/booking/booking-source.ts +20 -2
  189. package/src/lib/{checkout-breakdown.ts → booking/checkout-breakdown.ts} +1 -1
  190. package/src/lib/booking/correlation-id.ts +46 -0
  191. package/src/lib/{i18n → booking/i18n}/messages/en.json +48 -4
  192. package/src/lib/{i18n → booking/i18n}/messages/fr.json +48 -4
  193. package/src/lib/booking/itinerary-display.ts +36 -0
  194. package/src/lib/{itinerary-labels.ts → booking/itinerary-labels.ts} +1 -1
  195. package/src/lib/{location-calculations.ts → booking/location-calculations.ts} +4 -4
  196. package/src/lib/{location-utils.ts → booking/location-utils.ts} +26 -0
  197. package/src/lib/{map-utils.ts → booking/map-utils.ts} +3 -3
  198. package/src/lib/booking/normalize-booking-product-id.ts +7 -0
  199. package/src/lib/{pickup-location-types.ts → booking/pickup-location-types.ts} +2 -2
  200. package/src/lib/{pricing.ts → booking/pricing.ts} +2 -2
  201. package/src/lib/booking/product-option-id.ts +35 -0
  202. package/src/lib/booking/source-metadata.ts +72 -7
  203. package/src/lib/booking/sunday-week.ts +14 -0
  204. package/src/lib/booking/trace-context.ts +62 -0
  205. package/src/lib/booking-api.ts +1793 -0
  206. package/src/lib/{constants.ts → booking-constants.ts} +11 -5
  207. package/src/lib/booking-types.ts +36 -0
  208. package/src/lib/currency.ts +38 -45
  209. package/src/lib/dap-descriptions.ts +50 -0
  210. package/src/lib/dap-itinerary-preview.ts +315 -0
  211. package/src/lib/dependent-add-on-api.ts +434 -0
  212. package/src/lib/env.ts +89 -5
  213. package/src/lib/firebase.ts +20 -0
  214. package/src/lib/job-application-api.ts +83 -0
  215. package/src/lib/manage-booking-embed-print.ts +16 -0
  216. package/src/lib/manage-booking-post-checkout.ts +68 -0
  217. package/src/lib/photo-dap-config.ts +228 -0
  218. package/src/lib/pickup/map-utils.ts +56 -0
  219. package/src/lib/pickup/marker-icons.ts +19 -0
  220. package/src/lib/product-descriptions.ts +66 -0
  221. package/src/lib/products-config.ts +73 -0
  222. package/src/providers/booking-dialog-provider.tsx +107 -38
  223. package/src/providers/bottom-sheet-provider.tsx +40 -0
  224. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  225. package/src/radius.css +5 -0
  226. package/src/spacing.css +7 -0
  227. package/src/strings/en.json +1774 -0
  228. package/src/strings/es.json +1573 -0
  229. package/src/strings/fr.json +1573 -0
  230. package/src/strings/index.js +23 -0
  231. package/src/text-style.css +97 -0
  232. package/src/types/fareharbor.d.ts +12 -0
  233. package/src/types/quiz.ts +59 -0
  234. package/src/utils/currency-converter.ts +101 -0
  235. package/src/components/BookingFlow.tsx +0 -2952
  236. package/src/components/LanguageSwitcher.tsx +0 -30
  237. package/src/components/PrivateShuttleBookingFlow.tsx +0 -2290
  238. package/src/components/ProductList.tsx +0 -78
  239. package/src/components/WhatsAppPhoneInput.tsx +0 -224
  240. package/src/components/index.ts +0 -31
  241. package/src/lib/api.ts +0 -801
  242. package/src/lib/booking-api-auth.ts +0 -9
  243. package/src/lib/checkout-breakdown.test.ts +0 -70
  244. package/src/types/google-maps.d.ts +0 -2
  245. /package/src/components/{CurrencySwitcher.tsx → booking/CurrencySwitcher.tsx} +0 -0
  246. /package/src/components/{ErrorBoundary.tsx → booking/ErrorBoundary.tsx} +0 -0
  247. /package/src/lib/{i18n → booking/i18n}/config.ts +0 -0
  248. /package/src/lib/{i18n → booking/i18n}/index.tsx +0 -0
  249. /package/src/lib/{marker-icons.ts → booking/marker-icons.ts} +0 -0
  250. /package/src/lib/{places-api.ts → booking/places-api.ts} +0 -0
  251. /package/src/lib/{theme.ts → booking/theme.ts} +0 -0
  252. /package/src/lib/{utils.ts → booking/utils.ts} +0 -0
@@ -1,89 +1,56 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
4
- import Link from 'next/link';
3
+ import React, { useState, useCallback, useRef, useEffect, useLayoutEffect } from 'react';
4
+ import dynamic from 'next/dynamic';
5
5
  import { loadStripe } from '@stripe/stripe-js';
6
6
  import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
7
7
  import { formatCurrencyAmount } from '@/lib/currency';
8
8
  import { formatBookingRefForDisplay } from '@/lib/booking-ref';
9
- import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
10
- import { type Currency } from './CurrencySwitcher';
11
- import { useTranslations } from '@/lib/i18n';
12
- import { getStepLabel } from '@/lib/itinerary-labels';
13
- import { createBalancePaymentIntent, createManagePaymentIntent, type ItineraryDisplayStep } from '@/lib/api';
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';
14
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';
15
32
 
16
33
  const stripePromise = ENV.STRIPE_PUBLISHABLE_KEY ? loadStripe(ENV.STRIPE_PUBLISHABLE_KEY) : null;
17
34
 
18
- function isPickupOrDropOffStep(step: { stepType?: string }): boolean {
19
- return step.stepType === 'pickup' || step.stepType === 'drop_off';
20
- }
21
-
22
- function BalancePaymentForm({
23
- successUrl,
24
- onClose,
25
- t,
26
- balanceAmount,
27
- currency,
28
- }: {
29
- successUrl: string;
30
- onClose: () => void;
31
- t: (key: string) => string;
32
- balanceAmount: number;
33
- currency: Currency;
34
- }) {
35
- const stripe = useStripe();
36
- const elements = useElements();
37
- const [loading, setLoading] = useState(false);
38
- const [error, setError] = useState<string | null>(null);
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';
39
37
 
40
- const handleSubmit = async (e: React.FormEvent) => {
41
- e.preventDefault();
42
- if (!stripe || !elements) return;
43
- setLoading(true);
44
- setError(null);
45
- const { error: submitError } = await elements.submit();
46
- if (submitError) {
47
- setError(submitError.message ?? 'Validation failed');
48
- setLoading(false);
49
- return;
50
- }
51
- const { error: confirmError } = await stripe.confirmPayment({
52
- elements,
53
- confirmParams: { return_url: successUrl },
54
- });
55
- if (confirmError) {
56
- setError(confirmError.message ?? 'Payment failed');
57
- }
58
- setLoading(false);
59
- };
38
+ const COMMUNICATION_OPTIONS: { value: CommunicationMethod; label: string }[] = [
39
+ { value: 'EMAIL', label: 'Email' },
40
+ { value: 'WHATSAPP', label: 'WhatsApp' },
41
+ { value: 'SMS', label: 'SMS' },
42
+ ];
60
43
 
44
+ function WhatsAppIconSmall({ className }: { className?: string }) {
61
45
  return (
62
- <form onSubmit={handleSubmit} className="space-y-4">
63
- <PaymentElement />
64
- {error && (
65
- <p className="text-sm text-red-600" role="alert">{error}</p>
66
- )}
67
- <div className="flex gap-3 pt-2">
68
- <button
69
- type="button"
70
- onClick={onClose}
71
- className="flex-1 py-3 px-4 border border-stone-300 text-stone-700 rounded-lg hover:bg-stone-50"
72
- >
73
- {t('common.cancel') || 'Cancel'}
74
- </button>
75
- <button
76
- type="submit"
77
- disabled={!stripe || loading}
78
- className="flex-1 py-3 px-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
79
- >
80
- {loading ? 'Paying...' : `Pay remaining balance (${formatCurrencyAmount(balanceAmount, currency)})`}
81
- </button>
82
- </div>
83
- </form>
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>
84
49
  );
85
50
  }
86
51
 
52
+ const PickupLocationDialog = dynamic(() => import('@/components/PickupLocationDialog'), { ssr: false });
53
+
87
54
  export interface ReceiptLineItem {
88
55
  type: string;
89
56
  label: string;
@@ -110,8 +77,8 @@ export interface PaymentMethodUsage {
110
77
  currency: string;
111
78
  displayLabel?: string | null;
112
79
  reference?: string | null;
80
+ /** ISO 8601 timestamp when payment/refund was processed */
113
81
  paidAt?: string | null;
114
- paymentType?: string | null; // DEPOSIT | BALANCE | FULL
115
82
  }
116
83
 
117
84
  export interface PaymentPlan {
@@ -133,10 +100,18 @@ export interface PaymentDisplay {
133
100
 
134
101
  export interface CancellationPolicySnapshot {
135
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 */
136
106
  tiers?: Array<{
137
107
  hoursBeforeBooking: number;
138
108
  refundPercentage: number;
139
109
  }>;
110
+ /** Refund tiers: hours before booking + refund percent (e.g. from API/DynamoDB) */
111
+ refundTiers?: Array<{
112
+ hoursBefore: number;
113
+ refundPercent: number;
114
+ }>;
140
115
  }
141
116
 
142
117
  export interface Customer {
@@ -144,396 +119,2139 @@ export interface Customer {
144
119
  lastName?: string | null;
145
120
  email?: string | null;
146
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;
147
154
  }
148
155
 
149
156
  export interface BookingData {
150
157
  bookingReference: string;
158
+ /** GetYourGuide booking reference when booked via them */
159
+ gygBookingReference?: string | null;
151
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;
152
169
  status: string;
153
- productType?: string | null; // "STANDARD" | "PRIVATE_SHUTTLE" - for deposit receipt UI
154
170
  customer?: Customer | null;
155
171
  receipt: Receipt;
156
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;
157
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;
158
187
  travelerHotel?: string | null;
159
188
  cancellationPolicyId?: string | null;
160
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;
161
198
  communicationPreference?: string[] | null;
162
199
  itineraryDisplay?: ItineraryDisplayStep[] | null;
163
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;
164
235
  }
165
236
 
166
237
  export interface BookingDetailsProps {
167
238
  booking: BookingData;
168
239
  currency: Currency;
169
- /** When provided, show a refresh button in the balance-due section (e.g. after returning from Stripe) */
170
- onRefetch?: () => void;
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;
171
588
  }
172
589
 
173
590
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- currency kept for API; display uses booking.receipt.currency
174
- export function BookingDetails({ booking, currency, onRefetch }: BookingDetailsProps) {
175
- const { t } = useTranslations();
176
- const { receipt, payment, itineraryDisplay, cancellationPolicySnapshot, communicationPreference } = booking;
177
- const [showBalanceModal, setShowBalanceModal] = useState(false);
178
- const [balanceClientSecret, setBalanceClientSecret] = useState<string | null>(null);
179
- const [balanceLoading, setBalanceLoading] = useState(false);
180
- const [balanceError, setBalanceError] = useState<string | null>(null);
181
- const [paymentModalLabel, setPaymentModalLabel] = useState<string>('Pay remaining balance');
182
-
183
- const isDepositPaid = payment.status === 'DEPOSIT_PAID';
184
- const isAwaitingPayment = payment.status === 'AWAITING_PAYMENT';
185
- const balanceAmount = payment.plan?.balanceAmount ?? 0;
186
- const depositAmount = payment.plan?.depositAmount ?? 0;
187
- const hasBalanceDue = isDepositPaid && balanceAmount > 0;
188
- const hasPaymentDue = isAwaitingPayment && (depositAmount > 0 || balanceAmount > 0);
189
-
190
- const handlePayBalance = async () => {
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 () => {
191
720
  const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
192
721
  const ln = booking.customer?.lastName?.trim();
193
722
  if (!ln) {
194
- setBalanceError('Last name is required to pay balance');
723
+ setPaymentError('Last name is required');
195
724
  return;
196
725
  }
197
- setBalanceLoading(true);
198
- setBalanceError(null);
726
+ setPaymentLoading('deposit');
727
+ setPaymentError(null);
199
728
  try {
200
- const res = await createBalancePaymentIntent(ref, ln);
201
- setBalanceClientSecret(res.clientSecret);
202
- setPaymentModalLabel('Pay remaining balance');
203
- setShowBalanceModal(true);
729
+ const res = await createManagePaymentIntent(ref, ln, 'deposit');
730
+ setPaymentClientSecret(res.clientSecret);
731
+ setPaymentModalLabel('Pay deposit');
732
+ setShowPaymentModal(true);
204
733
  } catch (err) {
205
- setBalanceError(err instanceof Error ? err.message : 'Failed to start payment');
734
+ setPaymentError(err instanceof Error ? err.message : 'Failed to start payment');
206
735
  } finally {
207
- setBalanceLoading(false);
736
+ setPaymentLoading(null);
208
737
  }
209
- };
738
+ }, [booking.bookingReference, booking.customer?.lastName]);
210
739
 
211
- const handlePayDeposit = async () => {
740
+ const handlePayFull = useCallback(async () => {
212
741
  const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
213
742
  const ln = booking.customer?.lastName?.trim();
214
743
  if (!ln) {
215
- setBalanceError('Last name is required');
744
+ setPaymentError('Last name is required');
216
745
  return;
217
746
  }
218
- setBalanceLoading(true);
219
- setBalanceError(null);
747
+ setPaymentLoading('full');
748
+ setPaymentError(null);
220
749
  try {
221
- const res = await createManagePaymentIntent(ref, ln, 'deposit');
222
- setBalanceClientSecret(res.clientSecret);
223
- setPaymentModalLabel('Pay deposit');
224
- setShowBalanceModal(true);
750
+ const res = await createManagePaymentIntent(ref, ln, 'full');
751
+ setPaymentClientSecret(res.clientSecret);
752
+ setPaymentModalLabel('Pay full balance');
753
+ setShowPaymentModal(true);
225
754
  } catch (err) {
226
- setBalanceError(err instanceof Error ? err.message : 'Failed to start payment');
755
+ setPaymentError(err instanceof Error ? err.message : 'Failed to start payment');
227
756
  } finally {
228
- setBalanceLoading(false);
757
+ setPaymentLoading(null);
229
758
  }
230
- };
759
+ }, [booking.bookingReference, booking.customer?.lastName]);
231
760
 
232
- const handlePayFull = async () => {
761
+ const handlePayBalance = useCallback(async () => {
233
762
  const ref = formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference;
234
763
  const ln = booking.customer?.lastName?.trim();
235
764
  if (!ln) {
236
- setBalanceError('Last name is required');
765
+ setPaymentError('Last name is required');
237
766
  return;
238
767
  }
239
- setBalanceLoading(true);
240
- setBalanceError(null);
768
+ setPaymentLoading('balance');
769
+ setPaymentError(null);
241
770
  try {
242
- const res = await createManagePaymentIntent(ref, ln, 'full');
243
- setBalanceClientSecret(res.clientSecret);
244
- setPaymentModalLabel('Pay full balance');
245
- setShowBalanceModal(true);
771
+ const res = await createBalancePaymentIntent(ref, ln);
772
+ setPaymentClientSecret(res.clientSecret);
773
+ setPaymentModalLabel('Pay balance');
774
+ setShowPaymentModal(true);
246
775
  } catch (err) {
247
- setBalanceError(err instanceof Error ? err.message : 'Failed to start payment');
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');
248
866
  } finally {
249
- setBalanceLoading(false);
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;
250
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;
251
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
+ }
252
1212
 
253
1213
  return (
254
- <div className="max-w-4xl mx-auto px-4 py-8 space-y-8">
1214
+ <div className={styles.container}>
1215
+ {changeFlowSuccessMessage && (
1216
+ <div className={styles.changeBookingSuccess} role="status" aria-live="polite">
1217
+ {changeFlowSuccessMessage}
1218
+ </div>
1219
+ )}
255
1220
  {/* Header */}
256
- <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
257
- <div className="flex items-center gap-4 mb-4">
258
- <div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center">
259
- <svg className="w-8 h-8 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
260
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
261
- </svg>
262
- </div>
1221
+ <div className={styles.section}>
1222
+ <div className={styles.headerContent}>
1223
+ {headerIcon}
263
1224
  <div>
264
- <h1 className="text-2xl sm:text-3xl font-bold text-stone-900">Booking Confirmed</h1>
265
- <p className="text-stone-600">Reference: {formatBookingRefForDisplay(booking.bookingReference)}</p>
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}
266
1228
  </div>
267
1229
  </div>
268
- {booking.customer && (
269
- <p className="text-stone-700">
270
- Thank you{booking.customer.firstName ? `, ${booking.customer.firstName}` : ''}!
271
- A confirmation has been sent to {booking.customer.email || 'your email'}.
272
- </p>
273
- )}
1230
+ {bookingDetailsBlock}
274
1231
  </div>
275
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
+
276
1274
  {/* Your Itinerary */}
277
1275
  {itineraryDisplay && itineraryDisplay.length > 0 && (
278
- <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
279
- <h2 className="text-xl font-bold text-stone-900 mb-4 flex items-center gap-2">
280
- <svg className="w-5 h-5 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
281
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
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" />
282
1490
  </svg>
283
- Your Itinerary
1491
+ Pickup Details
284
1492
  </h2>
285
- <div className="space-y-3">
286
- {itineraryDisplay.map((step, index) => {
287
- const isUncertainStep = !booking.pickupLocationId && isPickupOrDropOffStep(step);
288
- const label = getStepLabel(step, t);
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 ?? '');
289
1504
  return (
290
- <div key={index} className="flex justify-between items-start gap-4 border-l-2 border-stone-200 pl-4">
291
- <span className="text-stone-700 font-medium">
292
- {label}
293
- </span>
294
- <span className="shrink-0 flex items-center gap-1.5">
295
- {isUncertainStep && !step.time ? (
296
- <>
297
- <span
298
- className="text-stone-400 cursor-help"
299
- title="Time not set — add your pickup location in Manage Booking for an estimate."
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"
300
1525
  >
301
- <svg className="w-4 h-4 inline align-middle" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
302
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
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" />
303
1529
  </svg>
304
- </span>
305
- <span className="text-stone-400 font-medium">TBD</span>
306
- </>
307
- ) : (
308
- step.time && <span className="text-stone-600">{step.time}</span>
309
- )}
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)}
310
1693
  </span>
311
1694
  </div>
312
- );
313
- })}
314
- </div>
315
- {!booking.pickupLocationId && itineraryDisplay.some(isPickupOrDropOffStep) && (
316
- <p className="mt-4 text-sm text-stone-500">
317
- <Link
318
- href={`/manage?ref=${encodeURIComponent(booking.bookingReference)}${booking.customer?.lastName ? `&lastName=${encodeURIComponent(booking.customer.lastName)}` : ''}`}
319
- className="text-stone-600 underline hover:text-stone-800"
320
- >
321
- Set or update your pickup location
322
- </Link>
323
- {' — we’ll show estimated pickup and drop-off times.'}
324
- </p>
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>
325
1742
  )}
326
1743
  </div>
327
- )}
328
1744
 
329
- {/* Payment Summary — shared PriceSummary component */}
330
- <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
331
- <h2 className="text-xl font-bold text-stone-900 mb-4 flex items-center gap-2">
332
- <svg className="w-5 h-5 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
333
- <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" />
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" />
334
1840
  </svg>
335
- Payment Summary
1841
+ Change Booking
336
1842
  </h2>
337
- <PriceSummary
338
- lines={receipt.lineItems.map((item): PriceSummaryLine => ({
339
- kind: 'line',
340
- label: item.label,
341
- amount: item.amount,
342
- type: item.type,
343
- quantity: item.quantity,
344
- }))}
345
- total={receipt.totalAmount}
346
- currency={receipt.currency as Currency}
347
- locale="en"
348
- subtotal={receipt.taxAmount > 0 ? receipt.subtotalBeforeTax : undefined}
349
- size="base"
350
- subtotalSpacing="relaxed"
351
- t={t}
352
- />
353
-
354
- {/* Payments — Deposit/Balance for private shuttle; Card/Gift card/No payment for pay-in-full */}
355
- {payment.methodUsages && payment.methodUsages.length > 0 && (
356
- <div className="mt-6 pt-4 border-t border-stone-200">
357
- <h3 className="text-sm font-semibold text-stone-700 mb-2">
358
- {payment.plan?.type === 'DEPOSIT' ? 'Payments' : 'Payment'}
359
- </h3>
360
- {payment.methodUsages.map((usage, index) => {
361
- const isDepositPlan = payment.plan?.type === 'DEPOSIT';
362
- const label = isDepositPlan
363
- ? (usage.paymentType === 'BALANCE' ? 'Balance' : 'Deposit')
364
- : (usage.type === 'CARD' ? (usage.displayLabel || 'Card') : usage.type === 'GIFT_CARD' ? (usage.displayLabel || 'Gift Card') : usage.displayLabel || 'No payment');
365
- return (
366
- <div key={index} className="flex justify-between text-stone-600 text-sm">
367
- <span>{label}{isDepositPlan && usage.displayLabel ? ` (${usage.displayLabel})` : ''}</span>
368
- <span>{formatCurrencyAmount(usage.amount, usage.currency as Currency)}</span>
369
- </div>
370
- );
371
- })}
372
- </div>
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>
373
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
+ />
374
1916
 
375
- {/* Payment status */}
376
- <div className="mt-4 pt-4 border-t border-stone-200">
377
- <div className="flex justify-between text-sm">
378
- <span className="text-stone-600">Status</span>
379
- <span className={`font-semibold ${
380
- payment.status === 'FULLY_PAID' ? 'text-emerald-600' :
381
- payment.status === 'DEPOSIT_PAID' ? 'text-amber-600' :
382
- 'text-stone-600'
383
- }`}>
384
- {payment.status === 'FULLY_PAID' && 'Fully Paid'}
385
- {payment.status === 'DEPOSIT_PAID' && 'Deposit Paid'}
386
- {payment.status === 'AWAITING_PAYMENT' && 'Payment Due'}
387
- {payment.status === 'PAYMENT_FAILED' && 'Payment Failed'}
388
- </span>
389
- </div>
390
- {hasPaymentDue && (
391
- <div className="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg p-4">
392
- <p className="font-semibold">Payment due</p>
393
- <p className="text-xs mt-1 text-amber-600">
394
- Deposit: {formatCurrencyAmount(depositAmount, receipt.currency as Currency)} • Balance: {formatCurrencyAmount(balanceAmount, receipt.currency as Currency)}
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.
395
1933
  </p>
396
- <p className="text-xs mt-2 text-amber-600">Pay now or the balance will be charged automatically before your booking.</p>
397
- {onRefetch && (
398
- <p className="text-xs mt-2">
399
- <button type="button" onClick={onRefetch} className="underline hover:no-underline text-amber-700">
400
- Just paid? Refresh to update
401
- </button>
402
- </p>
403
- )}
404
- {balanceError && (
405
- <p className="text-red-600 text-xs mt-2" role="alert">{balanceError}</p>
406
- )}
407
- <div className="mt-3 flex flex-col gap-2">
408
- <button
409
- type="button"
410
- onClick={handlePayDeposit}
411
- disabled={balanceLoading || !ENV.STRIPE_PUBLISHABLE_KEY}
412
- className="w-full py-2.5 px-4 bg-amber-600 hover:bg-amber-700 disabled:bg-stone-300 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
413
- >
414
- {balanceLoading ? 'Loading...' : `Pay deposit (${formatCurrencyAmount(depositAmount, receipt.currency as Currency)})`}
415
- </button>
416
- <button
417
- type="button"
418
- onClick={handlePayFull}
419
- disabled={balanceLoading || !ENV.STRIPE_PUBLISHABLE_KEY}
420
- className="w-full py-2.5 px-4 border border-amber-600 text-amber-700 hover:bg-amber-50 disabled:opacity-50 disabled:cursor-not-allowed font-semibold rounded-lg transition-colors"
421
- >
422
- Pay full balance ({formatCurrencyAmount(receipt.totalAmount, receipt.currency as Currency)})
423
- </button>
424
- </div>
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>
425
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>
426
1964
  )}
427
- {hasBalanceDue && !hasPaymentDue && (
428
- <div className="mt-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg p-4">
429
- <p className="font-semibold">Balance Due: {formatCurrencyAmount(balanceAmount, receipt.currency as Currency)}</p>
430
- {payment.balanceChargeScheduledAt && (
431
- <p className="text-xs mt-1 text-amber-600">Scheduled to be charged on: {new Date(payment.balanceChargeScheduledAt).toLocaleDateString()}</p>
432
- )}
433
- <p className="text-xs mt-2 text-amber-600">Pay now to complete your booking.</p>
434
- {onRefetch && (
435
- <p className="text-xs mt-2">
436
- <button type="button" onClick={onRefetch} className="underline hover:no-underline text-amber-700">
437
- Just paid? Refresh to update
438
- </button>
439
- </p>
440
- )}
441
- {balanceError && (
442
- <p className="text-red-600 text-xs mt-2" role="alert">{balanceError}</p>
443
- )}
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}>
444
2007
  <button
445
2008
  type="button"
446
- onClick={handlePayBalance}
447
- disabled={balanceLoading || !ENV.STRIPE_PUBLISHABLE_KEY}
448
- className="mt-3 w-full py-2.5 px-4 bg-amber-600 hover:bg-amber-700 disabled:bg-stone-300 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
2009
+ onClick={openCancelConfirmDialog}
2010
+ disabled={contactSaving}
2011
+ className={styles.contactSaveBtn}
449
2012
  >
450
- {balanceLoading ? 'Loading...' : `Pay remaining balance (${formatCurrencyAmount(balanceAmount, receipt.currency as Currency)})`}
2013
+ Cancel booking
451
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>}
452
2019
  </div>
453
2020
  )}
454
2021
  </div>
455
- </div>
2022
+ );
2023
+ })()}
456
2024
 
457
- {/* Cancellation Policy */}
458
- {cancellationPolicySnapshot && (
459
- <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
460
- <h2 className="text-xl font-bold text-stone-900 mb-4">Cancellation Policy</h2>
461
- <p className="text-stone-700 font-medium mb-2">{cancellationPolicySnapshot.label}</p>
462
- {cancellationPolicySnapshot.tiers && cancellationPolicySnapshot.tiers.length > 0 && (
463
- <div className="space-y-2 text-sm text-stone-600">
464
- {cancellationPolicySnapshot.tiers.map((tier, index) => (
465
- <p key={index}>
466
- {tier.hoursBeforeBooking}+ hours before: {tier.refundPercentage}% refund
467
- </p>
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>
468
2087
  ))}
469
2088
  </div>
470
- )}
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>
471
2115
  </div>
472
2116
  )}
473
2117
 
474
- {/* Contact & Communication */}
475
- <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
476
- <h2 className="text-xl font-bold text-stone-900 mb-4">Contact Information</h2>
477
- {booking.customer && (
478
- <div className="space-y-2 text-stone-700">
479
- {booking.customer.email && <p><span className="font-medium">Email:</span> {booking.customer.email}</p>}
480
- {booking.customer.phoneNumber && <p><span className="font-medium">Phone:</span> {booking.customer.phoneNumber}</p>}
481
- </div>
482
- )}
483
- {communicationPreference && communicationPreference.length > 0 && (
484
- <div className="mt-4 pt-4 border-t border-stone-200">
485
- <p className="text-sm text-stone-600">
486
- <span className="font-medium">Preferred communication:</span> {communicationPreference.join(', ')}
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?
487
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>
488
2148
  </div>
489
- )}
490
- </div>
491
-
492
- {/* Pickup Location */}
493
- {(booking.pickupLocationId || booking.travelerHotel) && (
494
- <div className="bg-white rounded-2xl shadow-lg p-6 sm:p-8">
495
- <h2 className="text-xl font-bold text-stone-900 mb-4">Pickup Details</h2>
496
- <p className="text-stone-700">
497
- {booking.travelerHotel || `Location ID: ${booking.pickupLocationId}`}
498
- </p>
499
2149
  </div>
500
2150
  )}
501
2151
 
502
- {/* Balance payment modal */}
503
- {showBalanceModal && balanceClientSecret && stripePromise && (
504
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
505
- <div className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden flex flex-col">
506
- <div className="p-6 border-b border-stone-200 flex-shrink-0">
507
- <div className="flex justify-between items-start">
508
- <h3 className="text-lg font-semibold text-stone-900">{paymentModalLabel}</h3>
509
- <button
510
- type="button"
511
- onClick={() => { setShowBalanceModal(false); setBalanceClientSecret(null); setBalanceError(null); setPaymentModalLabel('Pay remaining balance'); }}
512
- className="text-stone-400 hover:text-stone-600 p-1"
513
- aria-label="Close"
514
- >
515
- <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
516
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
517
- </svg>
518
- </button>
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>
519
2210
  </div>
520
- <p className="text-sm text-stone-600 mt-1">
521
- {formatCurrencyAmount(hasPaymentDue ? (paymentModalLabel === 'Pay deposit' ? depositAmount : receipt.totalAmount) : balanceAmount, receipt.currency as Currency)} due
522
- </p>
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
+ )}
523
2235
  </div>
524
- <div className="p-6 overflow-auto flex-1">
2236
+ <div className={styles.paymentModalBody}>
525
2237
  <Elements
526
2238
  stripe={stripePromise}
527
2239
  options={{
528
- clientSecret: balanceClientSecret,
2240
+ clientSecret: paymentClientSecret,
529
2241
  appearance: { theme: 'stripe' as const, variables: { colorPrimary: '#059669', borderRadius: '8px' } },
530
2242
  }}
531
2243
  >
532
- <BalancePaymentForm
533
- successUrl={`${typeof window !== 'undefined' ? window.location.origin : ''}/manage?ref=${encodeURIComponent(formatBookingRefForDisplay(booking.bookingReference) || booking.bookingReference)}&lastName=${encodeURIComponent(booking.customer?.lastName || '')}&payment=balance_success`}
534
- onClose={() => { setShowBalanceModal(false); setBalanceClientSecret(null); setBalanceError(null); setPaymentModalLabel('Pay remaining balance'); }}
535
- t={t}
536
- balanceAmount={hasPaymentDue ? (paymentModalLabel === 'Pay deposit' ? depositAmount : receipt.totalAmount) : balanceAmount}
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
+ }
537
2255
  currency={receipt.currency as Currency}
538
2256
  />
539
2257
  </Elements>