@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
@@ -0,0 +1,1350 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { format, parseISO } from 'date-fns';
12
+ import {
13
+ useDependentAddOnDialog,
14
+ } from '@/providers/dependent-add-on-dialog-provider';
15
+ import { ENV } from '@/lib/env';
16
+ import {
17
+ getDependentAddOnAvailability,
18
+ createDependentAddOnPaymentIntent,
19
+ type CreateDependentAddOnPaymentIntentResult,
20
+ type DependentAddOnCheckoutQuestion,
21
+ type DependentAddOnSlot,
22
+ type DapItineraryStep,
23
+ } from '@/lib/dependent-add-on-api';
24
+ import { dapItineraryStepsToDisplay } from '@/lib/dap-itinerary-preview';
25
+ import { DEFAULT_PHOTO_DAP_CANCELLATION_DAYS_BEFORE_SESSION } from '@/lib/photo-dap-config';
26
+ import { CheckoutModal, type CheckoutModalLineItem } from '@/components/booking/CheckoutModal';
27
+ import { DapFlowCollage } from '@/components/booking/DapFlowCollage';
28
+ import { DapTourDescription } from '@/components/booking/DapTourDescription';
29
+ import {
30
+ ItineraryReadOnlySummary,
31
+ type ItineraryReadOnlyPhotoPreview,
32
+ } from '@/components/booking/ItineraryBox';
33
+ import { formatBookingRefForDisplay } from '@/lib/booking-ref';
34
+ import type { Currency } from '@/lib/currency';
35
+ import { useLocale, useTranslations } from '@/lib/booking/i18n';
36
+ import Button, { ButtonHoverColor } from '@/components/button';
37
+ import checkoutFormStyles from './CheckoutForm.module.css';
38
+ import returnTimeStyles from './ReturnTimeSelector.module.css';
39
+ import bookingStyles from './BookingDialog.module.css';
40
+ import './booking-flow.css';
41
+
42
+ const DAP_SLOT_QUANTITY = 1;
43
+ /** Avoid preview API calls while the reference is still being typed. */
44
+ const DAP_REF_PREVIEW_MIN_LENGTH = 5;
45
+ const DAP_REF_PREVIEW_DEBOUNCE_MS = 450;
46
+
47
+ function slotsEqual(a: DependentAddOnSlot | null, b: DependentAddOnSlot): boolean {
48
+ return (
49
+ !!a &&
50
+ a.offeringId === b.offeringId &&
51
+ a.slotStart === b.slotStart &&
52
+ a.slotEnd === b.slotEnd
53
+ );
54
+ }
55
+
56
+ function newIdempotencyKey(): string {
57
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
58
+ return crypto.randomUUID();
59
+ }
60
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
61
+ }
62
+
63
+ function formatSlotInstant(iso: string): string {
64
+ try {
65
+ return new Date(iso).toLocaleString(undefined, {
66
+ weekday: 'short',
67
+ month: 'short',
68
+ day: 'numeric',
69
+ hour: 'numeric',
70
+ minute: '2-digit',
71
+ });
72
+ } catch {
73
+ return iso;
74
+ }
75
+ }
76
+
77
+ /** Same pattern as [BookingFlow] itinerary title: `format(parseISO(date), 'MMM d')`. */
78
+ function formatDapBookingDaySubtitle(iso: string | null | undefined): string | undefined {
79
+ if (!iso?.trim()) return undefined;
80
+ try {
81
+ return format(parseISO(iso.trim()), 'MMM d');
82
+ } catch {
83
+ try {
84
+ return format(new Date(iso.trim()), 'MMM d');
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ }
90
+
91
+ /** Single date line for a session (local calendar day of slot start). */
92
+ function formatSlotSessionDate(isoStart: string): string {
93
+ try {
94
+ return new Date(isoStart).toLocaleDateString(undefined, {
95
+ weekday: 'short',
96
+ month: 'short',
97
+ day: 'numeric',
98
+ year: 'numeric',
99
+ });
100
+ } catch {
101
+ return '';
102
+ }
103
+ }
104
+
105
+ /** Time–time only (same-day sessions; cross-day falls back to explicit start/end). */
106
+ function persistDapManageLastNameForRedirect(ref: string, lastName: string) {
107
+ const key = formatBookingRefForDisplay(ref.trim()) || ref.trim();
108
+ if (!key || !lastName.trim()) return;
109
+ try {
110
+ sessionStorage.setItem(`dap_manage_ln:${key}`, lastName.trim());
111
+ } catch {
112
+ /* ignore quota / private mode */
113
+ }
114
+ }
115
+
116
+ function validateDapCheckoutAnswersClient(
117
+ questions: DependentAddOnCheckoutQuestion[],
118
+ answers: Record<string, string>
119
+ ): string | null {
120
+ for (const q of questions) {
121
+ const raw = answers[q.id] ?? '';
122
+ if (q.type === 'TEXT') {
123
+ const v = raw.trim();
124
+ if (q.required && !v) {
125
+ return `Please answer: ${q.label}`;
126
+ }
127
+ const max = q.maxLength;
128
+ if (max != null && max > 0 && v.length > max) {
129
+ return `${q.label} must be at most ${max} characters.`;
130
+ }
131
+ } else {
132
+ const checked = raw === 'true';
133
+ if (q.required && !checked) {
134
+ return `Please confirm: ${q.label}`;
135
+ }
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function buildCheckoutAnswersForApi(
142
+ questions: DependentAddOnCheckoutQuestion[],
143
+ answers: Record<string, string>
144
+ ): Record<string, string> {
145
+ const out: Record<string, string> = {};
146
+ for (const q of questions) {
147
+ if (q.type === 'TEXT') {
148
+ out[q.id] = (answers[q.id] ?? '').trim();
149
+ } else {
150
+ out[q.id] = answers[q.id] === 'true' ? 'true' : 'false';
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+
156
+ function formatSlotTimeRange(isoStart: string, isoEnd: string): string {
157
+ try {
158
+ const start = new Date(isoStart);
159
+ const end = new Date(isoEnd);
160
+ const startDay = start.toDateString();
161
+ const endDay = end.toDateString();
162
+ const timeOpts: Intl.DateTimeFormatOptions = {
163
+ hour: 'numeric',
164
+ minute: '2-digit',
165
+ };
166
+ if (startDay !== endDay) {
167
+ return `${formatSlotInstant(isoStart)} – ${formatSlotInstant(isoEnd)}`;
168
+ }
169
+ const t1 = start.toLocaleTimeString(undefined, timeOpts);
170
+ const t2 = end.toLocaleTimeString(undefined, timeOpts);
171
+ return `${t1} – ${t2}`;
172
+ } catch {
173
+ return `${isoStart} – ${isoEnd}`;
174
+ }
175
+ }
176
+
177
+ function toCurrency(code: string): Currency {
178
+ const c = (code ?? 'CAD').toUpperCase();
179
+ if (c === 'USD' || c === 'EUR' || c === 'GBP' || c === 'AUD' || c === 'CAD') {
180
+ return c;
181
+ }
182
+ return 'CAD';
183
+ }
184
+
185
+ function getFocusableElements(container: HTMLElement): HTMLElement[] {
186
+ const selector =
187
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
188
+ return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter(
189
+ (el) =>
190
+ !el.hasAttribute('disabled') &&
191
+ el.offsetParent !== null &&
192
+ getComputedStyle(el).visibility !== 'hidden'
193
+ );
194
+ }
195
+
196
+ type Step = 'reference' | 'payment';
197
+
198
+ function dapSlotsPanelKey(ref: string, productOptionId: string): string {
199
+ return `${ref.trim()}|${productOptionId.trim()}`;
200
+ }
201
+
202
+ export default function DependentAddOnBookingDialog() {
203
+ const { t } = useTranslations();
204
+ const { locale } = useLocale();
205
+ const { isOpen, payload, close } = useDependentAddOnDialog();
206
+ const dialogRef = useRef<HTMLDivElement>(null);
207
+ const [step, setStep] = useState<Step>('reference');
208
+ const [primaryBookingReference, setPrimaryBookingReference] = useState('');
209
+ const [primaryBookingLastName, setPrimaryBookingLastName] = useState('');
210
+ const [selectedProductOptionId, setSelectedProductOptionId] = useState('');
211
+ const [slots, setSlots] = useState<DependentAddOnSlot[]>([]);
212
+ const [primaryItineraryDisplay, setPrimaryItineraryDisplay] = useState<
213
+ DapItineraryStep[]
214
+ >([]);
215
+ const [loadError, setLoadError] = useState<string | null>(null);
216
+ const [loadingSlots, setLoadingSlots] = useState(false);
217
+ const [selectedSlot, setSelectedSlot] = useState<DependentAddOnSlot | null>(null);
218
+ const [confirmError, setConfirmError] = useState<string | null>(null);
219
+ const [redirectLastName, setRedirectLastName] = useState('');
220
+ /** From GET availability — primary booking last name (no user typing). */
221
+ const [availabilityCustomerLastName, setAvailabilityCustomerLastName] = useState('');
222
+ const lastPiApiLastNameRef = useRef('');
223
+ const [paymentClientSecret, setPaymentClientSecret] = useState<string | null>(null);
224
+ const [paymentTotalAmount, setPaymentTotalAmount] = useState<number | null>(null);
225
+ const [paymentSubtotalAmount, setPaymentSubtotalAmount] = useState<number | null>(null);
226
+ const [paymentTaxAmount, setPaymentTaxAmount] = useState<number | null>(null);
227
+ const [paymentCurrency, setPaymentCurrency] = useState('CAD');
228
+ const [preparingPayment, setPreparingPayment] = useState(false);
229
+ /** When set, times below the form match this ref + option (inline flow — no separate “slots” step). */
230
+ const [slotsLoadedKey, setSlotsLoadedKey] = useState<string | null>(null);
231
+ const [refPreviewItinerary, setRefPreviewItinerary] = useState<DapItineraryStep[]>(
232
+ []
233
+ );
234
+ const [refPreviewStatus, setRefPreviewStatus] = useState<
235
+ 'idle' | 'loading' | 'success' | 'error'
236
+ >('idle');
237
+ const [refPreviewTargetRef, setRefPreviewTargetRef] = useState('');
238
+ /** First availability slot start — calendar day for title (matches main booking itinerary). */
239
+ const [dapBookingDaySlotStartIso, setDapBookingDaySlotStartIso] = useState<string | null>(
240
+ null
241
+ );
242
+ /**
243
+ * From GET availability when TicketBooth includes `cancellationDaysBeforeSession` on the DAP payload.
244
+ * Otherwise null — checkout copy uses `payload.cancellationDaysBeforeSession` from the catalog.
245
+ */
246
+ const [dapCancellationDaysFromAvailability, setDapCancellationDaysFromAvailability] = useState<
247
+ number | null
248
+ >(null);
249
+ const [dapCheckoutQuestions, setDapCheckoutQuestions] = useState<
250
+ DependentAddOnCheckoutQuestion[]
251
+ >([]);
252
+ const [dapCheckoutAnswers, setDapCheckoutAnswers] = useState<Record<string, string>>({});
253
+ const refPreviewSeqRef = useRef(0);
254
+ /** One automatic slots fetch per dialog open when payload pre-fills ref + last name + option (e.g. manage-booking upsell). */
255
+ const dapPrefillAutoSlotsConsumedRef = useRef(false);
256
+ const idempotencyKey = useMemo(
257
+ () => (selectedSlot ? newIdempotencyKey() : ''),
258
+ [selectedSlot]
259
+ );
260
+
261
+ const resetFlow = useCallback(() => {
262
+ setStep('reference');
263
+ setPrimaryBookingReference('');
264
+ setPrimaryBookingLastName('');
265
+ setSelectedProductOptionId('');
266
+ setSlots([]);
267
+ setPrimaryItineraryDisplay([]);
268
+ setLoadError(null);
269
+ setLoadingSlots(false);
270
+ setSelectedSlot(null);
271
+ setConfirmError(null);
272
+ setRedirectLastName('');
273
+ lastPiApiLastNameRef.current = '';
274
+ setAvailabilityCustomerLastName('');
275
+ setPaymentClientSecret(null);
276
+ setPaymentTotalAmount(null);
277
+ setPaymentSubtotalAmount(null);
278
+ setPaymentTaxAmount(null);
279
+ setPaymentCurrency('CAD');
280
+ setPreparingPayment(false);
281
+ setSlotsLoadedKey(null);
282
+ setRefPreviewItinerary([]);
283
+ setRefPreviewStatus('idle');
284
+ setRefPreviewTargetRef('');
285
+ setDapBookingDaySlotStartIso(null);
286
+ setDapCancellationDaysFromAvailability(null);
287
+ setDapCheckoutQuestions([]);
288
+ setDapCheckoutAnswers({});
289
+ refPreviewSeqRef.current += 1;
290
+ dapPrefillAutoSlotsConsumedRef.current = false;
291
+ }, []);
292
+
293
+ useEffect(() => {
294
+ if (!isOpen) {
295
+ resetFlow();
296
+ }
297
+ }, [isOpen, resetFlow]);
298
+
299
+ useLayoutEffect(() => {
300
+ if (!isOpen || !payload) return;
301
+ const fixed = payload.dependentAddOnProductOptionId?.trim();
302
+ const choices = payload.productOptions ?? [];
303
+ if (fixed) {
304
+ setSelectedProductOptionId(fixed);
305
+ } else if (choices.length > 1) {
306
+ const initial = payload.initialSelectedProductOptionId?.trim();
307
+ const match =
308
+ initial &&
309
+ choices.some((c) => c.dependentAddOnProductOptionId === initial);
310
+ setSelectedProductOptionId(match ? initial : '');
311
+ } else if (choices[0]) {
312
+ setSelectedProductOptionId(choices[0].dependentAddOnProductOptionId);
313
+ } else {
314
+ setSelectedProductOptionId('');
315
+ }
316
+ const initRef = payload.initialPrimaryBookingReference?.trim();
317
+ if (initRef) {
318
+ setPrimaryBookingReference(
319
+ formatBookingRefForDisplay(initRef) || initRef
320
+ );
321
+ }
322
+ const initLastName = payload.initialPrimaryBookingLastName?.trim();
323
+ if (initLastName) setPrimaryBookingLastName(initLastName);
324
+ }, [isOpen, payload]);
325
+
326
+ const resolvedProductOptionId = useMemo(() => {
327
+ if (!payload) return '';
328
+ const fixed = payload.dependentAddOnProductOptionId?.trim();
329
+ if (fixed) return fixed;
330
+ const choices = payload.productOptions ?? [];
331
+ const multiChoice = choices.length > 1;
332
+ if (multiChoice) {
333
+ return selectedProductOptionId.trim();
334
+ }
335
+ if (selectedProductOptionId.trim()) return selectedProductOptionId.trim();
336
+ return choices[0]?.dependentAddOnProductOptionId ?? '';
337
+ }, [payload, selectedProductOptionId]);
338
+
339
+ const showOptionPicker = Boolean(
340
+ payload &&
341
+ !payload.dependentAddOnProductOptionId &&
342
+ (payload.productOptions?.length ?? 0) > 1
343
+ );
344
+
345
+ /** Any valid option id for GET availability (itinerary is the same for all lengths on the same booking). */
346
+ const itineraryPreviewOptionId = useMemo(() => {
347
+ if (!payload) return '';
348
+ const fixed = payload.dependentAddOnProductOptionId?.trim();
349
+ if (fixed) return fixed;
350
+ const choices = payload.productOptions ?? [];
351
+ if (choices.length === 0) return '';
352
+ if (selectedProductOptionId.trim()) return selectedProductOptionId.trim();
353
+ return choices[0]?.dependentAddOnProductOptionId ?? '';
354
+ }, [payload, selectedProductOptionId]);
355
+
356
+ /** Pre-filled from opener (e.g. post-booking upsell): one GET availability covers preview + slots — skip debounced preview. */
357
+ const prefillCompleteForSingleAvailabilityFetch = useMemo(
358
+ () =>
359
+ Boolean(
360
+ payload?.initialPrimaryBookingReference?.trim() &&
361
+ payload?.initialPrimaryBookingLastName?.trim() &&
362
+ resolvedProductOptionId.trim()
363
+ ),
364
+ [
365
+ payload?.initialPrimaryBookingReference,
366
+ payload?.initialPrimaryBookingLastName,
367
+ resolvedProductOptionId,
368
+ ]
369
+ );
370
+
371
+ useEffect(() => {
372
+ if (!isOpen || !payload || step !== 'reference') return;
373
+ if (prefillCompleteForSingleAvailabilityFetch) return;
374
+ const refTrim = primaryBookingReference.trim();
375
+ const lastNameTrim = primaryBookingLastName.trim();
376
+ if (
377
+ refTrim.length < DAP_REF_PREVIEW_MIN_LENGTH ||
378
+ !lastNameTrim ||
379
+ !itineraryPreviewOptionId.trim()
380
+ ) {
381
+ return;
382
+ }
383
+ const seq = ++refPreviewSeqRef.current;
384
+ const timer = window.setTimeout(() => {
385
+ if (refPreviewSeqRef.current !== seq) return;
386
+ setRefPreviewTargetRef(refTrim);
387
+ setRefPreviewStatus('loading');
388
+ setRefPreviewItinerary([]);
389
+ setDapBookingDaySlotStartIso(null);
390
+ setDapCancellationDaysFromAvailability(null);
391
+ void (async () => {
392
+ try {
393
+ const preview = await getDependentAddOnAvailability({
394
+ companyId: ENV.COMPANY_ID,
395
+ primaryBookingReference: refTrim,
396
+ lastName: lastNameTrim,
397
+ dependentAddOnProductId: payload.dependentAddOnProductId,
398
+ dependentAddOnProductOptionId: itineraryPreviewOptionId.trim(),
399
+ });
400
+ if (refPreviewSeqRef.current !== seq) return;
401
+ setRefPreviewItinerary(preview.primaryItineraryDisplay);
402
+ setDapBookingDaySlotStartIso(preview.slots[0]?.slotStart ?? null);
403
+ setDapCancellationDaysFromAvailability(
404
+ preview.cancellationDaysBeforeSession ?? null
405
+ );
406
+ setRefPreviewStatus('success');
407
+ } catch {
408
+ if (refPreviewSeqRef.current !== seq) return;
409
+ setRefPreviewItinerary([]);
410
+ setDapBookingDaySlotStartIso(null);
411
+ setDapCancellationDaysFromAvailability(null);
412
+ setRefPreviewStatus('error');
413
+ }
414
+ })();
415
+ }, DAP_REF_PREVIEW_DEBOUNCE_MS);
416
+ return () => window.clearTimeout(timer);
417
+ }, [
418
+ isOpen,
419
+ payload,
420
+ step,
421
+ primaryBookingReference,
422
+ primaryBookingLastName,
423
+ itineraryPreviewOptionId,
424
+ prefillCompleteForSingleAvailabilityFetch,
425
+ ]);
426
+
427
+ useEffect(() => {
428
+ if (!isOpen) return;
429
+ const handleKeyDown = (e: KeyboardEvent) => {
430
+ if (e.key === 'Escape') {
431
+ close();
432
+ return;
433
+ }
434
+ if (e.key !== 'Tab' || !dialogRef.current) return;
435
+ const focusable = getFocusableElements(dialogRef.current);
436
+ if (focusable.length === 0) return;
437
+ const first = focusable[0];
438
+ const last = focusable[focusable.length - 1];
439
+ const currentIndex = focusable.indexOf(document.activeElement as HTMLElement);
440
+ if (e.shiftKey) {
441
+ if (currentIndex <= 0 || currentIndex === -1) {
442
+ e.preventDefault();
443
+ last.focus();
444
+ }
445
+ } else {
446
+ if (currentIndex === focusable.length - 1 || currentIndex === -1) {
447
+ e.preventDefault();
448
+ first.focus();
449
+ }
450
+ }
451
+ };
452
+ window.addEventListener('keydown', handleKeyDown);
453
+ return () => window.removeEventListener('keydown', handleKeyDown);
454
+ }, [isOpen, close]);
455
+
456
+ useEffect(() => {
457
+ if (isOpen && dialogRef.current) {
458
+ dialogRef.current.focus();
459
+ }
460
+ }, [isOpen]);
461
+
462
+ useEffect(() => {
463
+ if (isOpen) {
464
+ document.body.style.overflow = 'hidden';
465
+ return () => {
466
+ document.body.style.overflow = 'unset';
467
+ };
468
+ }
469
+ document.body.style.overflow = 'unset';
470
+ }, [isOpen]);
471
+
472
+ const clearPaymentPrepForSlotChange = useCallback(() => {
473
+ setPaymentClientSecret(null);
474
+ setPaymentTotalAmount(null);
475
+ setPaymentSubtotalAmount(null);
476
+ setPaymentTaxAmount(null);
477
+ lastPiApiLastNameRef.current = '';
478
+ setRedirectLastName('');
479
+ }, []);
480
+
481
+ const fetchAndShowSlots = useCallback(
482
+ async (ref: string, lastName: string, productOptionId: string | undefined) => {
483
+ if (!payload) return;
484
+ setLoadError(null);
485
+ clearPaymentPrepForSlotChange();
486
+ setLoadingSlots(true);
487
+ setSelectedSlot(null);
488
+ setSlotsLoadedKey(null);
489
+ setSlots([]);
490
+ setPrimaryItineraryDisplay([]);
491
+ setDapCancellationDaysFromAvailability(null);
492
+ setDapCheckoutQuestions([]);
493
+ setDapCheckoutAnswers({});
494
+ const optKey = (productOptionId ?? '').trim();
495
+ try {
496
+ const avail = await getDependentAddOnAvailability({
497
+ companyId: ENV.COMPANY_ID,
498
+ primaryBookingReference: ref,
499
+ lastName,
500
+ dependentAddOnProductId: payload.dependentAddOnProductId,
501
+ dependentAddOnProductOptionId: productOptionId || undefined,
502
+ });
503
+ const list = avail.slots;
504
+ const itinerary = avail.primaryItineraryDisplay;
505
+ const fetchedLn = avail.primaryCustomerLastName;
506
+ setSlots(list);
507
+ setPrimaryItineraryDisplay(itinerary);
508
+ setDapBookingDaySlotStartIso(list[0]?.slotStart ?? null);
509
+ setAvailabilityCustomerLastName((fetchedLn ?? '').trim());
510
+ setDapCancellationDaysFromAvailability(
511
+ avail.cancellationDaysBeforeSession ?? null
512
+ );
513
+ setDapCheckoutQuestions(avail.checkoutQuestions ?? []);
514
+ setDapCheckoutAnswers({});
515
+ setSlotsLoadedKey(dapSlotsPanelKey(ref, optKey));
516
+ } catch (err) {
517
+ setLoadError(err instanceof Error ? err.message : 'Could not load times.');
518
+ } finally {
519
+ setLoadingSlots(false);
520
+ }
521
+ },
522
+ [payload, clearPaymentPrepForSlotChange]
523
+ );
524
+
525
+ useEffect(() => {
526
+ if (!isOpen || !payload || step !== 'reference') return;
527
+ if (!prefillCompleteForSingleAvailabilityFetch) return;
528
+ if (dapPrefillAutoSlotsConsumedRef.current) return;
529
+ const initRef = payload.initialPrimaryBookingReference?.trim();
530
+ const initLn = payload.initialPrimaryBookingLastName?.trim();
531
+ if (!initRef || !initLn) return;
532
+ const refTrim = primaryBookingReference.trim();
533
+ const lastNameTrim = primaryBookingLastName.trim();
534
+ const opt = resolvedProductOptionId.trim();
535
+ if (
536
+ refTrim.length < DAP_REF_PREVIEW_MIN_LENGTH ||
537
+ !lastNameTrim ||
538
+ !opt
539
+ ) {
540
+ return;
541
+ }
542
+ if (
543
+ formatBookingRefForDisplay(initRef) !== refTrim ||
544
+ initLn !== lastNameTrim
545
+ ) {
546
+ return;
547
+ }
548
+ dapPrefillAutoSlotsConsumedRef.current = true;
549
+ void fetchAndShowSlots(refTrim, lastNameTrim, opt);
550
+ }, [
551
+ isOpen,
552
+ payload,
553
+ step,
554
+ prefillCompleteForSingleAvailabilityFetch,
555
+ primaryBookingReference,
556
+ primaryBookingLastName,
557
+ resolvedProductOptionId,
558
+ fetchAndShowSlots,
559
+ ]);
560
+
561
+ const handleLoadSlots = useCallback(
562
+ async (e: React.FormEvent) => {
563
+ e.preventDefault();
564
+ if (!payload) return;
565
+ const ref = primaryBookingReference.trim();
566
+ const lastName = primaryBookingLastName.trim();
567
+ if (!ref) {
568
+ setLoadError('Enter your shuttle booking reference.');
569
+ return;
570
+ }
571
+ if (!lastName) {
572
+ setLoadError('Enter the booking last name.');
573
+ return;
574
+ }
575
+ const needsCatalogOption =
576
+ Boolean(payload.dependentAddOnProductOptionId) ||
577
+ (payload.productOptions?.length ?? 0) > 0;
578
+ if (needsCatalogOption && !resolvedProductOptionId) {
579
+ setLoadError('Choose a session length.');
580
+ return;
581
+ }
582
+ await fetchAndShowSlots(ref, lastName, resolvedProductOptionId || undefined);
583
+ },
584
+ [payload, primaryBookingReference, primaryBookingLastName, resolvedProductOptionId, fetchAndShowSlots]
585
+ );
586
+
587
+ const handleSessionOptionSelect = useCallback(
588
+ async (optionId: string) => {
589
+ if (!payload) return;
590
+ const ref = primaryBookingReference.trim();
591
+ const lastName = primaryBookingLastName.trim();
592
+ if (!ref) {
593
+ setLoadError('Enter your shuttle booking reference.');
594
+ return;
595
+ }
596
+ if (!lastName) {
597
+ setLoadError('Enter the booking last name.');
598
+ return;
599
+ }
600
+ setSelectedProductOptionId(optionId);
601
+ await fetchAndShowSlots(ref, lastName, optionId);
602
+ },
603
+ [payload, primaryBookingReference, primaryBookingLastName, fetchAndShowSlots]
604
+ );
605
+
606
+ const goToPaymentStep = async () => {
607
+ if (!selectedSlot) return;
608
+ setConfirmError(null);
609
+ if (dapCheckoutQuestions.length > 0) {
610
+ const qErr = validateDapCheckoutAnswersClient(
611
+ dapCheckoutQuestions,
612
+ dapCheckoutAnswers
613
+ );
614
+ if (qErr) {
615
+ setConfirmError(qErr);
616
+ return;
617
+ }
618
+ }
619
+ if (!paymentClientSecret) {
620
+ const pi = await preparePaymentIntent();
621
+ if (!pi) return;
622
+ }
623
+ const apiLn = lastPiApiLastNameRef.current.trim();
624
+ const ln = apiLn || availabilityCustomerLastName.trim();
625
+ if (!ln) {
626
+ setConfirmError(t('booking.dapNoLastNameOnFile'));
627
+ return;
628
+ }
629
+ setRedirectLastName(ln);
630
+ persistDapManageLastNameForRedirect(primaryBookingReference, ln);
631
+ setStep('payment');
632
+ };
633
+
634
+ const preparePaymentIntent = async (): Promise<CreateDependentAddOnPaymentIntentResult | null> => {
635
+ if (!payload || !selectedSlot) return null;
636
+ const ref = primaryBookingReference.trim();
637
+ setConfirmError(null);
638
+ setPaymentSubtotalAmount(null);
639
+ setPaymentTaxAmount(null);
640
+ setPreparingPayment(true);
641
+ try {
642
+ const pi = await createDependentAddOnPaymentIntent({
643
+ companyId: ENV.COMPANY_ID,
644
+ primaryBookingReference: ref,
645
+ lastName: primaryBookingLastName.trim(),
646
+ dependentAddOnProductId: payload.dependentAddOnProductId,
647
+ dependentAddOnProductOptionId: resolvedProductOptionId || undefined,
648
+ offeringId: selectedSlot.offeringId,
649
+ slotStart: selectedSlot.slotStart,
650
+ slotEnd: selectedSlot.slotEnd,
651
+ quantity: DAP_SLOT_QUANTITY,
652
+ idempotencyKey,
653
+ ...(dapCheckoutQuestions.length > 0
654
+ ? {
655
+ checkoutAnswers: buildCheckoutAnswersForApi(
656
+ dapCheckoutQuestions,
657
+ dapCheckoutAnswers
658
+ ),
659
+ }
660
+ : {}),
661
+ });
662
+ const secret = pi.clientSecret;
663
+ if (!secret) {
664
+ setConfirmError('Payment could not be started. Please try again.');
665
+ return null;
666
+ }
667
+ const apiLn = (pi.customerLastName ?? '').trim();
668
+ lastPiApiLastNameRef.current = apiLn;
669
+ setPaymentClientSecret(secret);
670
+ setPaymentTotalAmount(pi.totalAmount ?? null);
671
+ setPaymentSubtotalAmount(
672
+ pi.subtotalAmount ?? (pi.taxAmount != null && pi.totalAmount != null
673
+ ? pi.totalAmount - pi.taxAmount
674
+ : pi.totalAmount ?? null)
675
+ );
676
+ setPaymentTaxAmount(
677
+ typeof pi.taxAmount === 'number' && pi.taxAmount > 0 ? pi.taxAmount : null
678
+ );
679
+ setPaymentCurrency(pi.currency ?? 'CAD');
680
+ setRedirectLastName(apiLn);
681
+ return pi;
682
+ } catch (err) {
683
+ const msg = err instanceof Error ? err.message : 'Could not start payment.';
684
+ setConfirmError(msg);
685
+ return null;
686
+ } finally {
687
+ setPreparingPayment(false);
688
+ }
689
+ };
690
+
691
+ const goBackToSlots = () => {
692
+ setStep('reference');
693
+ setPaymentClientSecret(null);
694
+ setPaymentTotalAmount(null);
695
+ setPaymentSubtotalAmount(null);
696
+ setPaymentTaxAmount(null);
697
+ lastPiApiLastNameRef.current = '';
698
+ setRedirectLastName('');
699
+ };
700
+
701
+ const manageBookingAfterPayUrl = useMemo(() => {
702
+ if (typeof window === 'undefined') return '/manage-booking';
703
+ const ref =
704
+ formatBookingRefForDisplay(primaryBookingReference.trim()) ||
705
+ primaryBookingReference.trim();
706
+ const ln = redirectLastName.trim();
707
+ if (!ref) return `${window.location.origin}/manage-booking`;
708
+ const q = new URLSearchParams({
709
+ ref,
710
+ dap_success: '1',
711
+ });
712
+ if (ln) q.set('lastName', ln);
713
+ return `${window.location.origin}/manage-booking?${q.toString()}#booking-add-ons`;
714
+ }, [primaryBookingReference, redirectLastName]);
715
+
716
+ const sessionLengthLabel = useMemo(() => {
717
+ if (!payload) return null;
718
+ return (
719
+ payload.productOptions?.find(
720
+ (o) => o.dependentAddOnProductOptionId === resolvedProductOptionId
721
+ )?.label ?? null
722
+ );
723
+ }, [payload, resolvedProductOptionId]);
724
+
725
+ const slotsPanelVisible = useMemo(() => {
726
+ if (step !== 'reference') return false;
727
+ if (!slotsLoadedKey) return false;
728
+ return (
729
+ slotsLoadedKey ===
730
+ dapSlotsPanelKey(primaryBookingReference, resolvedProductOptionId)
731
+ );
732
+ }, [
733
+ step,
734
+ slotsLoadedKey,
735
+ primaryBookingReference,
736
+ resolvedProductOptionId,
737
+ ]);
738
+
739
+ const refTrimSummary = primaryBookingReference.trim();
740
+ const slotsKeyMatchSummary =
741
+ slotsLoadedKey ===
742
+ dapSlotsPanelKey(refTrimSummary, resolvedProductOptionId);
743
+
744
+ const refSummarySteps = useMemo(() => {
745
+ if (refTrimSummary.length < DAP_REF_PREVIEW_MIN_LENGTH) return [];
746
+ if (slotsKeyMatchSummary) return primaryItineraryDisplay;
747
+ if (
748
+ refPreviewTargetRef === refTrimSummary &&
749
+ refPreviewStatus === 'success'
750
+ ) {
751
+ return refPreviewItinerary;
752
+ }
753
+ return [];
754
+ }, [
755
+ refTrimSummary,
756
+ slotsKeyMatchSummary,
757
+ primaryItineraryDisplay,
758
+ refPreviewTargetRef,
759
+ refPreviewStatus,
760
+ refPreviewItinerary,
761
+ ]);
762
+
763
+ const refSummaryPhase = useMemo(():
764
+ | 'idle'
765
+ | 'loading'
766
+ | 'ready'
767
+ | 'error' => {
768
+ if (refTrimSummary.length < DAP_REF_PREVIEW_MIN_LENGTH) return 'idle';
769
+ if (slotsKeyMatchSummary) return 'ready';
770
+ if (refPreviewTargetRef !== refTrimSummary) return 'idle';
771
+ if (refPreviewStatus === 'loading') return 'loading';
772
+ if (refPreviewStatus === 'error') return 'error';
773
+ if (refPreviewStatus === 'success') return 'ready';
774
+ return 'idle';
775
+ }, [
776
+ refTrimSummary,
777
+ slotsKeyMatchSummary,
778
+ refPreviewTargetRef,
779
+ refPreviewStatus,
780
+ ]);
781
+
782
+ const refSummaryItineraryDisplay = useMemo(
783
+ () => dapItineraryStepsToDisplay(refSummarySteps),
784
+ [refSummarySteps]
785
+ );
786
+
787
+ const dapPhotoSessionPreview = useMemo((): ItineraryReadOnlyPhotoPreview | null => {
788
+ if (!selectedSlot || !slotsPanelVisible || refSummaryPhase !== 'ready') return null;
789
+ return {
790
+ sessionTimeRangeLabel: formatSlotTimeRange(
791
+ selectedSlot.slotStart,
792
+ selectedSlot.slotEnd
793
+ ),
794
+ photographerLabel: selectedSlot.resourceName ?? selectedSlot.resourceId,
795
+ sessionLengthLabel,
796
+ primaryItinerarySteps: primaryItineraryDisplay,
797
+ slotStartIso: selectedSlot.slotStart,
798
+ slotEndIso: selectedSlot.slotEnd,
799
+ };
800
+ }, [
801
+ selectedSlot,
802
+ slotsPanelVisible,
803
+ refSummaryPhase,
804
+ sessionLengthLabel,
805
+ primaryItineraryDisplay,
806
+ ]);
807
+
808
+ const dapItineraryDateSubtitle = useMemo(() => {
809
+ if (refSummaryPhase !== 'ready') return undefined;
810
+ return formatDapBookingDaySubtitle(dapBookingDaySlotStartIso);
811
+ }, [refSummaryPhase, dapBookingDaySlotStartIso]);
812
+
813
+ const dapCancellationDays = useMemo(() => {
814
+ if (dapCancellationDaysFromAvailability != null) {
815
+ return dapCancellationDaysFromAvailability;
816
+ }
817
+ const fromPayload = payload?.cancellationDaysBeforeSession;
818
+ if (fromPayload != null && Number.isFinite(fromPayload) && fromPayload >= 1) {
819
+ return Math.min(Math.floor(fromPayload), 365);
820
+ }
821
+ return DEFAULT_PHOTO_DAP_CANCELLATION_DAYS_BEFORE_SESSION;
822
+ }, [dapCancellationDaysFromAvailability, payload?.cancellationDaysBeforeSession]);
823
+
824
+ const dapCancellationDaysUnit = t(
825
+ dapCancellationDays === 1
826
+ ? 'booking.dapCancellationPolicyDayUnit'
827
+ : 'booking.dapCancellationPolicyDaysUnit'
828
+ );
829
+
830
+ const dapCheckoutSummary = useMemo(() => {
831
+ if (!payload || !selectedSlot) return null;
832
+ const lineTitle = sessionLengthLabel
833
+ ? `${payload.productDisplayTitle} (${sessionLengthLabel})`
834
+ : payload.productDisplayTitle;
835
+ const subtotal =
836
+ paymentSubtotalAmount ??
837
+ (selectedSlot.price != null ? selectedSlot.price * DAP_SLOT_QUANTITY : 0);
838
+ const tax = paymentTaxAmount ?? 0;
839
+ const total =
840
+ paymentTotalAmount != null ? paymentTotalAmount : subtotal + tax;
841
+ const ticketLines: CheckoutModalLineItem[] = [
842
+ {
843
+ line: {
844
+ category: lineTitle,
845
+ qty: 1,
846
+ pricePerUnit: subtotal,
847
+ itemTotal: subtotal,
848
+ },
849
+ breakdown: null,
850
+ },
851
+ ];
852
+ return { ticketLines, subtotal, tax, total };
853
+ }, [
854
+ payload,
855
+ selectedSlot,
856
+ sessionLengthLabel,
857
+ paymentSubtotalAmount,
858
+ paymentTaxAmount,
859
+ paymentTotalAmount,
860
+ ]);
861
+
862
+ if (!isOpen || !payload) return null;
863
+
864
+ const title =
865
+ step === 'payment' ? 'Payment' : payload.productDisplayTitle;
866
+
867
+ return (
868
+ <>
869
+ <div className={bookingStyles.overlay} onClick={close}>
870
+ <div
871
+ ref={dialogRef}
872
+ className={`booking-flow-root ${bookingStyles.dialog}`}
873
+ onClick={(e) => e.stopPropagation()}
874
+ role="dialog"
875
+ aria-modal="true"
876
+ aria-labelledby="dap-dialog-title"
877
+ tabIndex={-1}
878
+ >
879
+ <header className={bookingStyles.header}>
880
+ <div className={bookingStyles.headerLeft}>
881
+ {step === 'payment' ? (
882
+ <button
883
+ type="button"
884
+ className={bookingStyles.backButton}
885
+ onClick={goBackToSlots}
886
+ aria-label="Go back"
887
+ >
888
+ <svg
889
+ width="24"
890
+ height="24"
891
+ viewBox="0 0 24 24"
892
+ fill="none"
893
+ stroke="currentColor"
894
+ strokeWidth="2"
895
+ strokeLinecap="round"
896
+ strokeLinejoin="round"
897
+ >
898
+ <path d="M19 12H5M12 19l-7-7 7-7" />
899
+ </svg>
900
+ </button>
901
+ ) : (
902
+ <span className={bookingStyles.headerSpacer} aria-hidden />
903
+ )}
904
+ </div>
905
+ <h2 id="dap-dialog-title" className={bookingStyles.title}>
906
+ {title}
907
+ </h2>
908
+ <div className={bookingStyles.headerRight}>
909
+ <button
910
+ type="button"
911
+ className={bookingStyles.closeButton}
912
+ onClick={close}
913
+ aria-label="Close"
914
+ >
915
+ <svg
916
+ width="24"
917
+ height="24"
918
+ viewBox="0 0 24 24"
919
+ fill="none"
920
+ stroke="currentColor"
921
+ strokeWidth="2"
922
+ strokeLinecap="round"
923
+ strokeLinejoin="round"
924
+ >
925
+ <path d="M18 6L6 18M6 6l12 12" />
926
+ </svg>
927
+ </button>
928
+ </div>
929
+ </header>
930
+
931
+ <div className={`${bookingStyles.content} booking-flow-preflight`}>
932
+ <div className={`${bookingStyles.screen} dap-add-on-dialog`}>
933
+ {(payload.collageImageIds?.length || payload.dapDescriptionSlug) ? (
934
+ <div className={bookingStyles.dapTopMedia}>
935
+ {payload.collageImageIds && payload.collageImageIds.length > 0 ? (
936
+ <div className="booking-collage-wrapper">
937
+ <DapFlowCollage
938
+ imageIds={payload.collageImageIds}
939
+ altPrefix={payload.productDisplayTitle}
940
+ />
941
+ </div>
942
+ ) : null}
943
+ {payload.dapDescriptionSlug ? (
944
+ <DapTourDescription slug={payload.dapDescriptionSlug} />
945
+ ) : null}
946
+ </div>
947
+ ) : null}
948
+ {step === 'reference' && (
949
+ <>
950
+ <form onSubmit={handleLoadSlots} className={bookingStyles.dapForm}>
951
+ <label className={bookingStyles.dapField}>
952
+ <span className={bookingStyles.dapLabel}>Booking reference</span>
953
+ <input
954
+ type="text"
955
+ value={primaryBookingReference}
956
+ onChange={(e) => {
957
+ refPreviewSeqRef.current += 1;
958
+ setPrimaryBookingReference(e.target.value);
959
+ setRefPreviewItinerary([]);
960
+ setRefPreviewStatus('idle');
961
+ setRefPreviewTargetRef('');
962
+ setSlotsLoadedKey(null);
963
+ setSlots([]);
964
+ setPrimaryItineraryDisplay([]);
965
+ setDapBookingDaySlotStartIso(null);
966
+ setDapCancellationDaysFromAvailability(null);
967
+ setSelectedSlot(null);
968
+ setLoadError(null);
969
+ }}
970
+ placeholder="e.g. QGR5WBWZ"
971
+ autoComplete="off"
972
+ disabled={loadingSlots}
973
+ />
974
+ </label>
975
+ <label className={bookingStyles.dapField}>
976
+ <span className={bookingStyles.dapLabel}>Last name</span>
977
+ <input
978
+ type="text"
979
+ value={primaryBookingLastName}
980
+ onChange={(e) => {
981
+ refPreviewSeqRef.current += 1;
982
+ setPrimaryBookingLastName(e.target.value);
983
+ setRefPreviewItinerary([]);
984
+ setRefPreviewStatus('idle');
985
+ setRefPreviewTargetRef('');
986
+ setLoadError(null);
987
+ }}
988
+ placeholder="As entered at booking"
989
+ autoComplete="family-name"
990
+ disabled={loadingSlots}
991
+ />
992
+ </label>
993
+ {refSummaryPhase !== 'idle' ? (
994
+ <ItineraryReadOnlySummary
995
+ title={t('booking.dapYourCurrentItinerary')}
996
+ dateSubtitle={dapItineraryDateSubtitle}
997
+ itineraryItems={
998
+ refSummaryPhase === 'ready'
999
+ ? refSummaryItineraryDisplay
1000
+ : null
1001
+ }
1002
+ status={
1003
+ refSummaryPhase === 'loading'
1004
+ ? 'loading'
1005
+ : refSummaryPhase === 'error'
1006
+ ? 'error'
1007
+ : 'ready'
1008
+ }
1009
+ loadingMessage={t('booking.dapRefItineraryLoading')}
1010
+ errorMessage={t('booking.dapRefItineraryLookupFailed')}
1011
+ emptyMessage={t('booking.dapRefItineraryEmpty')}
1012
+ t={t}
1013
+ sticky
1014
+ photoSessionPreview={dapPhotoSessionPreview}
1015
+ className={bookingStyles.dapRefItinerarySummary}
1016
+ titleClassName={bookingStyles.dapRefItinerarySummaryTitle}
1017
+ />
1018
+ ) : null}
1019
+ {showOptionPicker && payload.productOptions && (
1020
+ <div className={bookingStyles.dapField}>
1021
+ <span className={bookingStyles.dapLegend} id="dap-session-length-label">
1022
+ Session length
1023
+ </span>
1024
+ <div
1025
+ className={bookingStyles.dapSessionOptionGrid}
1026
+ role="group"
1027
+ aria-labelledby="dap-session-length-label"
1028
+ >
1029
+ {payload.productOptions.map((opt) => {
1030
+ const selected =
1031
+ selectedProductOptionId === opt.dependentAddOnProductOptionId;
1032
+ return (
1033
+ <button
1034
+ key={opt.dependentAddOnProductOptionId}
1035
+ type="button"
1036
+ disabled={loadingSlots}
1037
+ className={
1038
+ selected
1039
+ ? `${bookingStyles.dapSessionOptionBtn} ${bookingStyles.dapSessionOptionBtnSelected}`
1040
+ : bookingStyles.dapSessionOptionBtn
1041
+ }
1042
+ onClick={() =>
1043
+ void handleSessionOptionSelect(
1044
+ opt.dependentAddOnProductOptionId
1045
+ )
1046
+ }
1047
+ >
1048
+ <span className={bookingStyles.dapSessionOptionTitle}>
1049
+ {opt.label}
1050
+ </span>
1051
+ {opt.photosLabel ? (
1052
+ <span className={bookingStyles.dapSessionOptionMeta}>
1053
+ {opt.photosLabel}
1054
+ </span>
1055
+ ) : null}
1056
+ {opt.startingAtLabel ? (
1057
+ <span className={bookingStyles.dapSessionOptionPrice}>
1058
+ {opt.startingAtLabel}
1059
+ </span>
1060
+ ) : null}
1061
+ </button>
1062
+ );
1063
+ })}
1064
+ </div>
1065
+ </div>
1066
+ )}
1067
+ {showOptionPicker && loadingSlots && (
1068
+ <p className={bookingStyles.dapIntro} aria-live="polite">
1069
+ Loading available times…
1070
+ </p>
1071
+ )}
1072
+ {loadError && (
1073
+ <p className={bookingStyles.dapError} role="alert">
1074
+ {loadError}
1075
+ </p>
1076
+ )}
1077
+ {!showOptionPicker && (
1078
+ <button
1079
+ type="submit"
1080
+ disabled={loadingSlots}
1081
+ className="dap-add-on-primary"
1082
+ >
1083
+ {loadingSlots ? 'Loading…' : 'See available times'}
1084
+ </button>
1085
+ )}
1086
+ {slotsPanelVisible ? (
1087
+ <div
1088
+ className={`${bookingStyles.dapForm} ${bookingStyles.dapFormSlotsTight} ${bookingStyles.dapSlotsPanelBelow}`}
1089
+ >
1090
+ <p className={bookingStyles.dapMetaLine}>
1091
+ Booking{' '}
1092
+ <span className={bookingStyles.dapRefMono}>
1093
+ {formatBookingRefForDisplay(primaryBookingReference) ||
1094
+ primaryBookingReference.trim()}
1095
+ </span>
1096
+ {sessionLengthLabel ? (
1097
+ <>
1098
+ {' '}
1099
+ · Session: {sessionLengthLabel}
1100
+ </>
1101
+ ) : null}
1102
+ </p>
1103
+ {slots.length === 0 ? (
1104
+ <p className={bookingStyles.dapIntro}>
1105
+ No times are available for this add-on with your booking. Contact us
1106
+ if you need help finding a time that fits your trip.
1107
+ </p>
1108
+ ) : (
1109
+ <>
1110
+ <label
1111
+ className={`${returnTimeStyles.label} ${bookingStyles.dapSessionPickerLabel}`}
1112
+ >
1113
+ {t('booking.selectPhotoSessionTime')}
1114
+ </label>
1115
+ <div className={returnTimeStyles.list}>
1116
+ {slots.map((slot) => {
1117
+ const id = `${slot.offeringId}-${slot.slotStart}-${slot.slotEnd}`;
1118
+ const selected = slotsEqual(selectedSlot, slot);
1119
+ const photographer = slot.resourceName ?? slot.resourceId;
1120
+ const spotsLeft = slot.capacityRemaining;
1121
+ const soldOut = spotsLeft === 0;
1122
+ const sessionDateLabel = formatSlotSessionDate(slot.slotStart);
1123
+ const sessionTimeRange = formatSlotTimeRange(
1124
+ slot.slotStart,
1125
+ slot.slotEnd
1126
+ );
1127
+ const priceLine =
1128
+ slot.price != null && slot.currency
1129
+ ? `${slot.currency} ${slot.price.toFixed(2)}`
1130
+ : '—';
1131
+ return (
1132
+ <div key={id} className={bookingStyles.dapSlotRow}>
1133
+ <button
1134
+ type="button"
1135
+ aria-pressed={selected}
1136
+ disabled={soldOut}
1137
+ className={`${returnTimeStyles.btn} ${
1138
+ soldOut
1139
+ ? returnTimeStyles.btnDisabled
1140
+ : selected
1141
+ ? `${returnTimeStyles.btnSelected} ${bookingStyles.dapSlotBtnSelected}`
1142
+ : returnTimeStyles.btnAvailable
1143
+ }`}
1144
+ onClick={() => {
1145
+ if (soldOut) return;
1146
+ clearPaymentPrepForSlotChange();
1147
+ setSelectedSlot((prev) =>
1148
+ slotsEqual(prev, slot) ? null : slot
1149
+ );
1150
+ setConfirmError(null);
1151
+ }}
1152
+ >
1153
+ <div className={returnTimeStyles.content}>
1154
+ <div className={returnTimeStyles.details}>
1155
+ <div className={returnTimeStyles.time}>
1156
+ <div className={bookingStyles.dapSlotDateLine}>
1157
+ {sessionDateLabel}
1158
+ </div>
1159
+ <div className={bookingStyles.dapSlotTimeRangeLine}>
1160
+ {sessionTimeRange}
1161
+ </div>
1162
+ </div>
1163
+ <div className={returnTimeStyles.location}>
1164
+ Photographer: {photographer}
1165
+ </div>
1166
+ {soldOut ? (
1167
+ <div className={returnTimeStyles.soldOut}>
1168
+ {t('booking.soldOut')}
1169
+ </div>
1170
+ ) : null}
1171
+ </div>
1172
+ <div
1173
+ className={`${returnTimeStyles.price} ${returnTimeStyles.priceIncluded}`}
1174
+ >
1175
+ {priceLine}
1176
+ </div>
1177
+ </div>
1178
+ </button>
1179
+ </div>
1180
+ );
1181
+ })}
1182
+ </div>
1183
+ {selectedSlot && dapCheckoutQuestions.length > 0 ? (
1184
+ <div className={bookingStyles.dapCheckoutQuestions}>
1185
+ {dapCheckoutQuestions.map((q) => (
1186
+ <div key={q.id} className={bookingStyles.dapCheckoutQuestionField}>
1187
+ {q.type === 'TEXT' ? (
1188
+ <>
1189
+ <label
1190
+ className={bookingStyles.dapCheckoutQuestionLabel}
1191
+ htmlFor={`dap-checkout-q-${q.id}`}
1192
+ >
1193
+ {q.label}
1194
+ {q.required ? (
1195
+ <span
1196
+ className={checkoutFormStyles.required}
1197
+ aria-hidden
1198
+ >
1199
+ {' '}
1200
+ *
1201
+ </span>
1202
+ ) : null}
1203
+ </label>
1204
+ <textarea
1205
+ id={`dap-checkout-q-${q.id}`}
1206
+ className={checkoutFormStyles.input}
1207
+ rows={3}
1208
+ value={dapCheckoutAnswers[q.id] ?? ''}
1209
+ placeholder={q.placeholder ?? undefined}
1210
+ maxLength={q.maxLength ?? undefined}
1211
+ onChange={(e) => {
1212
+ setDapCheckoutAnswers((prev) => ({
1213
+ ...prev,
1214
+ [q.id]: e.target.value,
1215
+ }));
1216
+ setConfirmError(null);
1217
+ }}
1218
+ />
1219
+ {q.helpText ? (
1220
+ <p className={bookingStyles.dapCheckoutQuestionHelp}>
1221
+ {q.helpText}
1222
+ </p>
1223
+ ) : null}
1224
+ </>
1225
+ ) : (
1226
+ <div className={bookingStyles.dapCheckoutCheckboxQuestion}>
1227
+ <label className={checkoutFormStyles.adminCheckbox}>
1228
+ <input
1229
+ type="checkbox"
1230
+ checked={dapCheckoutAnswers[q.id] === 'true'}
1231
+ onChange={(e) => {
1232
+ setDapCheckoutAnswers((prev) => ({
1233
+ ...prev,
1234
+ [q.id]: e.target.checked ? 'true' : 'false',
1235
+ }));
1236
+ setConfirmError(null);
1237
+ }}
1238
+ />
1239
+ <span>
1240
+ {q.label}
1241
+ {q.required ? (
1242
+ <span
1243
+ className={checkoutFormStyles.required}
1244
+ aria-hidden
1245
+ >
1246
+ {' '}
1247
+ *
1248
+ </span>
1249
+ ) : null}
1250
+ </span>
1251
+ </label>
1252
+ {q.helpText ? (
1253
+ <p className={bookingStyles.dapCheckoutQuestionHelp}>
1254
+ {q.helpText}
1255
+ </p>
1256
+ ) : null}
1257
+ </div>
1258
+ )}
1259
+ </div>
1260
+ ))}
1261
+ </div>
1262
+ ) : null}
1263
+ {confirmError && (
1264
+ <p className={bookingStyles.dapError} role="alert">
1265
+ {confirmError}
1266
+ </p>
1267
+ )}
1268
+ <div
1269
+ className={`${checkoutFormStyles.section} ${bookingStyles.dapCheckoutSectionPlain}`}
1270
+ >
1271
+ <p className={bookingStyles.dapCancellationFinePrint}>
1272
+ <strong>{t('booking.dapCancellationPolicyHeading')}:</strong>{' '}
1273
+ {t('booking.dapCancellationPolicyBody', {
1274
+ days: dapCancellationDays,
1275
+ daysUnit: dapCancellationDaysUnit,
1276
+ })}
1277
+ </p>
1278
+ <div
1279
+ className={`${checkoutFormStyles.submitBtnWrapper} ${bookingStyles.dapSubmitBtnWrapper}`}
1280
+ >
1281
+ <Button
1282
+ type="button"
1283
+ disabled={!selectedSlot || preparingPayment}
1284
+ onClick={() => void goToPaymentStep()}
1285
+ isLarge
1286
+ hoverColor={ButtonHoverColor.Turquoise}
1287
+ className={checkoutFormStyles.submitBtn}
1288
+ >
1289
+ {t('booking.continueToPayment')}
1290
+ </Button>
1291
+ </div>
1292
+ <p className={checkoutFormStyles.secureNote}>
1293
+ {t('booking.securePayment')}
1294
+ </p>
1295
+ </div>
1296
+ </>
1297
+ )}
1298
+ </div>
1299
+ ) : null}
1300
+ </form>
1301
+ </>
1302
+ )}
1303
+
1304
+ {step === 'payment' && selectedSlot ? (
1305
+ <p className="sr-only">
1306
+ {t('booking.reviewAndPay') || 'Review and pay'}
1307
+ </p>
1308
+ ) : null}
1309
+
1310
+ </div>
1311
+ </div>
1312
+ </div>
1313
+ </div>
1314
+
1315
+ {step === 'payment' && selectedSlot && dapCheckoutSummary ? (
1316
+ <CheckoutModal
1317
+ open
1318
+ onClose={() => {
1319
+ goBackToSlots();
1320
+ }}
1321
+ clientSecret={paymentClientSecret ?? ''}
1322
+ reservationReference={
1323
+ formatBookingRefForDisplay(primaryBookingReference.trim()) ||
1324
+ primaryBookingReference.trim()
1325
+ }
1326
+ successUrlOverride={manageBookingAfterPayUrl}
1327
+ ticketLines={dapCheckoutSummary.ticketLines}
1328
+ feeLineItems={[]}
1329
+ returnPriceAdjustment={0}
1330
+ cancellationPolicyFee={0}
1331
+ subtotal={dapCheckoutSummary.subtotal}
1332
+ tax={dapCheckoutSummary.tax}
1333
+ total={dapCheckoutSummary.total}
1334
+ promoDiscountAmount={0}
1335
+ discountLabel={null}
1336
+ totalQuantity={1}
1337
+ isTaxIncludedInPrice={false}
1338
+ taxRate={
1339
+ dapCheckoutSummary.tax > 0 && dapCheckoutSummary.subtotal > 0
1340
+ ? dapCheckoutSummary.tax / dapCheckoutSummary.subtotal
1341
+ : 0
1342
+ }
1343
+ currency={toCurrency(paymentCurrency)}
1344
+ locale={locale}
1345
+ t={t}
1346
+ />
1347
+ ) : null}
1348
+ </>
1349
+ );
1350
+ }