@ticketboothapp/booking 1.2.24 → 1.2.25-rc.0

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 (158) hide show
  1. package/package.json +29 -2
  2. package/src/assets/icons/minus.svg +7 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2264 -0
  8. package/src/components/BookingWidget.tsx +302 -0
  9. package/src/components/ManageBookingView.tsx +437 -0
  10. package/src/components/PhoneInputWithCountry.module.css +131 -0
  11. package/src/components/PhoneInputWithCountry.tsx +44 -0
  12. package/src/components/PickupLocationDialog.module.css +360 -0
  13. package/src/components/PickupLocationDialog.tsx +357 -0
  14. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  15. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  16. package/src/components/booking/AddOnsSection.module.css +10 -0
  17. package/src/components/booking/AddOnsSection.tsx +184 -0
  18. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  19. package/src/components/booking/BookingDialog.module.css +643 -0
  20. package/src/components/booking/BookingDialog.tsx +356 -0
  21. package/src/components/booking/BookingFlow.tsx +4385 -0
  22. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  23. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  24. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  25. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  26. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  27. package/src/components/booking/BookingProductGrid.module.css +359 -0
  28. package/src/components/booking/BookingProductGrid.tsx +497 -0
  29. package/src/components/booking/Calendar.module.css +616 -0
  30. package/src/components/booking/Calendar.tsx +1123 -0
  31. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  32. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  33. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  34. package/src/components/booking/CheckoutForm.module.css +244 -0
  35. package/src/components/booking/CheckoutForm.tsx +364 -0
  36. package/src/components/booking/CheckoutModal.tsx +451 -0
  37. package/src/components/booking/CurrencySwitcher.tsx +81 -0
  38. package/src/components/booking/DapFlowCollage.tsx +88 -0
  39. package/src/components/booking/DapTourDescription.tsx +35 -0
  40. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  41. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  42. package/src/components/booking/ErrorBoundary.tsx +63 -0
  43. package/src/components/booking/InfoTooltip.tsx +108 -0
  44. package/src/components/booking/ItineraryBox.module.css +258 -0
  45. package/src/components/booking/ItineraryBox.tsx +550 -0
  46. package/src/components/booking/ItineraryBuilder.tsx +82 -0
  47. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  48. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  49. package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
  50. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  51. package/src/components/booking/PickupLocationSelector.tsx +1566 -0
  52. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  53. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  54. package/src/components/booking/PriceBreakdown.tsx +154 -0
  55. package/src/components/booking/PriceSummary.tsx +234 -0
  56. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  57. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  58. package/src/components/booking/PromoCodeInput.module.css +166 -0
  59. package/src/components/booking/PromoCodeInput.tsx +99 -0
  60. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  61. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  62. package/src/components/booking/TermsAcceptance.tsx +111 -0
  63. package/src/components/booking/TicketSelector.module.css +164 -0
  64. package/src/components/booking/TicketSelector.tsx +199 -0
  65. package/src/components/booking/TourDescription.module.css +304 -0
  66. package/src/components/booking/TourDescription.tsx +273 -0
  67. package/src/components/booking/booking-flow-ui.ts +38 -0
  68. package/src/components/booking/booking-flow.css +944 -0
  69. package/src/components/button.css +245 -0
  70. package/src/components/button.tsx +152 -0
  71. package/src/components/colorable-svg.tsx +29 -0
  72. package/src/components/image.css +29 -0
  73. package/src/components/image.tsx +113 -0
  74. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  75. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  76. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
  77. package/src/components/product-tag.module.css +30 -0
  78. package/src/components/product-tag.tsx +34 -0
  79. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  80. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  81. package/src/components/terms/TermsContent.tsx +178 -0
  82. package/src/components/value-pill.module.css +59 -0
  83. package/src/components/value-pill.tsx +46 -0
  84. package/src/constants/images.ts +556 -0
  85. package/src/constants/pill-values.ts +210 -0
  86. package/src/constants/products.ts +155 -0
  87. package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
  88. package/src/contexts/BookingAppContext.tsx +134 -0
  89. package/src/contexts/CompanyContext.tsx +70 -0
  90. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  91. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  92. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  93. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  94. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  95. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  96. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  97. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  98. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  99. package/src/data/product-descriptions/private-tour.en.json +80 -0
  100. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  101. package/src/data/products-config.json +101 -0
  102. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  103. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  104. package/src/index.ts +79 -0
  105. package/src/lib/analytics.ts +197 -0
  106. package/src/lib/booking/booking-source.ts +51 -0
  107. package/src/lib/booking/checkout-breakdown.ts +69 -0
  108. package/src/lib/booking/correlation-id.ts +46 -0
  109. package/src/lib/booking/i18n/config.ts +21 -0
  110. package/src/lib/booking/i18n/index.tsx +144 -0
  111. package/src/lib/booking/i18n/messages/en.json +236 -0
  112. package/src/lib/booking/i18n/messages/fr.json +236 -0
  113. package/src/lib/booking/itinerary-display.ts +36 -0
  114. package/src/lib/booking/itinerary-labels.ts +70 -0
  115. package/src/lib/booking/location-calculations.ts +43 -0
  116. package/src/lib/booking/location-utils.ts +165 -0
  117. package/src/lib/booking/map-utils.ts +153 -0
  118. package/src/lib/booking/marker-icons.ts +113 -0
  119. package/src/lib/booking/normalize-booking-product-id.ts +21 -0
  120. package/src/lib/booking/pickup-location-types.ts +25 -0
  121. package/src/lib/booking/places-api.ts +154 -0
  122. package/src/lib/booking/pricing.ts +466 -0
  123. package/src/lib/booking/product-option-id.ts +35 -0
  124. package/src/lib/booking/source-metadata.ts +226 -0
  125. package/src/lib/booking/sunday-week.ts +14 -0
  126. package/src/lib/booking/theme.ts +83 -0
  127. package/src/lib/booking/trace-context.ts +62 -0
  128. package/src/lib/booking/utils.ts +9 -0
  129. package/src/lib/booking-api.ts +1793 -0
  130. package/src/lib/booking-constants.ts +23 -0
  131. package/src/lib/booking-ref.ts +13 -0
  132. package/src/lib/booking-types.ts +36 -0
  133. package/src/lib/currency.ts +81 -0
  134. package/src/lib/dap-descriptions.ts +50 -0
  135. package/src/lib/dap-itinerary-preview.ts +315 -0
  136. package/src/lib/dependent-add-on-api.ts +434 -0
  137. package/src/lib/env.ts +96 -0
  138. package/src/lib/firebase.ts +20 -0
  139. package/src/lib/job-application-api.ts +83 -0
  140. package/src/lib/manage-booking-embed-print.ts +16 -0
  141. package/src/lib/manage-booking-post-checkout.ts +68 -0
  142. package/src/lib/photo-dap-config.ts +228 -0
  143. package/src/lib/photo-packages.ts +75 -0
  144. package/src/lib/pickup/map-utils.ts +56 -0
  145. package/src/lib/pickup/marker-icons.ts +19 -0
  146. package/src/lib/product-descriptions.ts +66 -0
  147. package/src/lib/products-config.ts +73 -0
  148. package/src/providers/booking-dialog-provider.tsx +282 -0
  149. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  150. package/src/radius.css +5 -0
  151. package/src/spacing.css +7 -0
  152. package/src/strings/en.json +1774 -0
  153. package/src/strings/es.json +1573 -0
  154. package/src/strings/fr.json +1573 -0
  155. package/src/strings/index.js +23 -0
  156. package/src/text-style.css +56 -0
  157. package/src/utils/currency-converter.ts +101 -0
  158. package/tsconfig.json +8 -2
@@ -0,0 +1,550 @@
1
+ 'use client';
2
+
3
+ import { Fragment, useRef, useEffect, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import type { ItineraryDisplayStep } from '@/lib/booking-api';
6
+ import { ItineraryStepType as StepType } from '@/lib/booking-api';
7
+ import { getStepLabel } from '@/lib/booking/itinerary-labels';
8
+ import type { PickupLocation } from '@/lib/booking-api';
9
+ import type { DapItineraryStep } from '@/lib/dap-itinerary-preview';
10
+ import { getPhotoSessionInsertPosition } from '@/lib/dap-itinerary-preview';
11
+ import styles from './ItineraryBox.module.css';
12
+
13
+ type TranslationFn = (key: string, params?: Record<string, string>) => string;
14
+
15
+ const TOOLTIP_TEXT = 'Approximate time - will be finalized when you select a pickup location';
16
+
17
+ function TooltipAnchor({
18
+ showTooltip,
19
+ isMobile,
20
+ onTooltipToggle,
21
+ onTooltipShow,
22
+ }: {
23
+ showTooltip: boolean;
24
+ isMobile: boolean;
25
+ onTooltipToggle: () => void;
26
+ onTooltipShow: (show: boolean) => void;
27
+ }) {
28
+ const anchorRef = useRef<HTMLSpanElement>(null);
29
+ const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
30
+
31
+ useEffect(() => {
32
+ if (!showTooltip || !anchorRef.current || typeof document === 'undefined') return;
33
+ const rect = anchorRef.current.getBoundingClientRect();
34
+ const vw = typeof window !== 'undefined' ? window.innerWidth : 375;
35
+ const vh = typeof window !== 'undefined' ? window.innerHeight : 667;
36
+ const tooltipWidth = 280;
37
+ const padding = 16;
38
+
39
+ if (isMobile) {
40
+ const effectiveWidth = Math.min(tooltipWidth, vw - padding * 2);
41
+ const leftClamped = Math.max(padding, Math.min(rect.left, vw - effectiveWidth - padding));
42
+ const spaceBelow = vh - rect.bottom - padding;
43
+ const preferAbove = spaceBelow < 100;
44
+
45
+ setTooltipStyle({
46
+ position: 'fixed',
47
+ left: leftClamped,
48
+ ...(preferAbove
49
+ ? { bottom: vh - rect.top + 4, maxHeight: rect.top - padding }
50
+ : { top: rect.bottom + 4, maxHeight: Math.min(200, spaceBelow - 8) }),
51
+ width: effectiveWidth,
52
+ overflowY: 'auto',
53
+ zIndex: 99999,
54
+ });
55
+ } else {
56
+ const maxW = Math.min(320, vw - padding * 2);
57
+ const centerX = rect.left + rect.width / 2;
58
+ const left = Math.max(padding + maxW / 2, Math.min(centerX, vw - padding - maxW / 2));
59
+
60
+ setTooltipStyle({
61
+ position: 'fixed',
62
+ left,
63
+ top: rect.top - 4,
64
+ transform: 'translate(-50%, -100%)',
65
+ maxWidth: maxW,
66
+ zIndex: 99999,
67
+ });
68
+ }
69
+ }, [showTooltip, isMobile]);
70
+
71
+ return (
72
+ <>
73
+ <span className="relative inline-block" data-tooltip-icon ref={anchorRef}>
74
+ <svg
75
+ className="w-4 h-4 text-stone-400 cursor-pointer shrink-0"
76
+ fill="none"
77
+ stroke="currentColor"
78
+ viewBox="0 0 24 24"
79
+ onClick={(e) => {
80
+ e.stopPropagation();
81
+ onTooltipToggle();
82
+ }}
83
+ onMouseEnter={() => !isMobile && onTooltipShow(true)}
84
+ onMouseLeave={() => !isMobile && onTooltipShow(false)}
85
+ >
86
+ <path
87
+ strokeLinecap="round"
88
+ strokeLinejoin="round"
89
+ strokeWidth={2}
90
+ d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
91
+ />
92
+ </svg>
93
+ </span>
94
+ {showTooltip &&
95
+ typeof document !== 'undefined' &&
96
+ createPortal(
97
+ <span
98
+ className="whitespace-normal text-xs bg-stone-800 text-white px-3 py-2 rounded shadow-lg pointer-events-none"
99
+ style={tooltipStyle}
100
+ >
101
+ {TOOLTIP_TEXT}
102
+ </span>,
103
+ document.body
104
+ )}
105
+ </>
106
+ );
107
+ }
108
+
109
+ interface ItineraryBoxProps {
110
+ selectedDate: string;
111
+ formattedDate: string;
112
+ itineraryItems: ItineraryDisplayStep[] | null;
113
+ isBookingComplete: boolean;
114
+ isItinerarySticky: boolean;
115
+ /** When set, CSS `position: sticky` uses this `top` (px) so the box clears host sticky headers. */
116
+ stickyTopPx?: number;
117
+ isMobile: boolean;
118
+ useWindowScroll?: boolean;
119
+ showTooltip: boolean;
120
+ selectedPickupLocation: PickupLocation | null | undefined;
121
+ pickupLocationSkipped: boolean;
122
+ pickupLocationsCount: number;
123
+ itineraryRef: React.RefObject<HTMLDivElement | null>;
124
+ t: TranslationFn;
125
+ onTooltipToggle: () => void;
126
+ onTooltipShow: (show: boolean) => void;
127
+ }
128
+
129
+ export function ItineraryBox({
130
+ selectedDate,
131
+ formattedDate,
132
+ itineraryItems,
133
+ isBookingComplete,
134
+ isItinerarySticky,
135
+ stickyTopPx = 0,
136
+ isMobile,
137
+ useWindowScroll = false,
138
+ showTooltip,
139
+ selectedPickupLocation,
140
+ pickupLocationSkipped,
141
+ pickupLocationsCount,
142
+ itineraryRef,
143
+ t,
144
+ onTooltipToggle,
145
+ onTooltipShow,
146
+ }: ItineraryBoxProps) {
147
+ if (!selectedDate) return null;
148
+ if (!itineraryItems || itineraryItems.length === 0) return null;
149
+
150
+ const abbreviateDestination = (text: string): string => {
151
+ const abbreviations: Record<string, string> = {
152
+ 'Moraine Lake': 'ML',
153
+ 'Lake Louise': 'LL',
154
+ 'Banff': 'B',
155
+ 'Jasper': 'J',
156
+ 'Canmore': 'C',
157
+ };
158
+ for (const [full, abbrev] of Object.entries(abbreviations)) {
159
+ if (text.includes(full)) {
160
+ return text.replace(full, abbrev);
161
+ }
162
+ }
163
+ return text;
164
+ };
165
+
166
+ const handlePickupLocationClick = () => {
167
+ const pickupSection = document.getElementById('pickup-location-section');
168
+ if (!pickupSection) return;
169
+
170
+ const findScrollParent = (node: HTMLElement): HTMLElement | null => {
171
+ let parent = node.parentElement;
172
+ while (parent) {
173
+ const { overflowY } = getComputedStyle(parent);
174
+ if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') return parent;
175
+ parent = parent.parentElement;
176
+ }
177
+ return null;
178
+ };
179
+
180
+ const scrollParent = useWindowScroll ? null : findScrollParent(pickupSection);
181
+ const itineraryBox = document.querySelector('[class*="sticky top-"]');
182
+ const itineraryHeight = itineraryBox?.getBoundingClientRect().height ?? 0;
183
+ // Extra offset so sticky header doesn't block the pickup section (mobile needs more clearance)
184
+ const STICKY_HEADER_CLEARANCE = isMobile ? 280 : 270;
185
+ const targetOffsetFromTop = itineraryHeight + 16 + STICKY_HEADER_CLEARANCE;
186
+
187
+ if (scrollParent) {
188
+ const elementPosition = pickupSection.getBoundingClientRect().top;
189
+ const scrollDelta = elementPosition - targetOffsetFromTop;
190
+ scrollParent.scrollTo({
191
+ top: Math.max(0, scrollParent.scrollTop + scrollDelta),
192
+ behavior: 'smooth',
193
+ });
194
+ } else if (typeof window !== 'undefined') {
195
+ const elementPosition = pickupSection.getBoundingClientRect().top + window.scrollY;
196
+ const scrollTop = Math.max(0, elementPosition - targetOffsetFromTop);
197
+ window.scrollTo({ top: scrollTop, behavior: 'smooth' });
198
+ }
199
+ };
200
+
201
+ const getDisplayLabel = (label: string): string => {
202
+ if (isItinerarySticky && isMobile) {
203
+ return abbreviateDestination(label);
204
+ }
205
+ return label;
206
+ };
207
+
208
+ const stickyTop = Math.max(0, stickyTopPx ?? 0);
209
+
210
+ return (
211
+ <div
212
+ ref={itineraryRef as React.RefObject<HTMLDivElement>}
213
+ className={`${styles.box} ${isItinerarySticky ? styles.boxSticky : styles.boxExpanded}`}
214
+ style={stickyTop > 0 ? { top: stickyTop } : undefined}
215
+ >
216
+ <h3 className={`${styles.title} ${isItinerarySticky ? styles.titleSticky : styles.titleExpanded}`}>
217
+ {!isBookingComplete && (
218
+ <svg className={`${styles.icon} ${isItinerarySticky ? styles.iconSticky : styles.iconExpanded}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
219
+ <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" />
220
+ </svg>
221
+ )}
222
+ {t('booking.buildYourItinerary')}
223
+ {formattedDate && (
224
+ <span className={styles.dateSubtitle}> · {formattedDate}</span>
225
+ )}
226
+ </h3>
227
+ {itineraryItems.length > 0 && (
228
+ <div className={isItinerarySticky ? styles.itemsSticky : styles.itemsExpanded}>
229
+ {itineraryItems.map((item, index) => {
230
+ const itemLabel = getStepLabel(item, t);
231
+ const isUncertain =
232
+ !item.time ||
233
+ (!selectedPickupLocation &&
234
+ !pickupLocationSkipped &&
235
+ (item.stepType === StepType.pickup || item.stepType === StepType.drop_off));
236
+ const hasTime = !!item.time;
237
+ const isPlaceholder = item.stepType === StepType.trip_end && !item.time;
238
+ const isPickupTime = index === 0 && item.stepType === StepType.pickup;
239
+ const isApproximatePickupTime = isPickupTime && !selectedPickupLocation && (pickupLocationsCount > 0 || pickupLocationSkipped);
240
+
241
+ let formattedTime = '';
242
+ if (item.time) {
243
+ formattedTime = item.time.replace(/^at /i, '');
244
+ formattedTime = formattedTime.replace(/:00(?=\s*(AM|PM))/i, '');
245
+ }
246
+
247
+ const isUncertainPickupOrDropOff =
248
+ isUncertain && (item.stepType === StepType.pickup || item.stepType === StepType.drop_off);
249
+
250
+ const locationOnlyDisplay =
251
+ item.place === 'your_pickup_location' ? t('booking.yourPickupLocation') : (item.place ?? '');
252
+ const prefixKey =
253
+ item.stepType === StepType.pickup ? 'booking.pickupAtPrefix' : 'booking.dropOffAtPrefix';
254
+
255
+ const content = (
256
+ <>
257
+ {hasTime ? (
258
+ <span className="inline-flex items-center gap-1.5 flex-wrap">
259
+ <span className={`inline-flex items-center gap-1.5 shrink-0 ${(formattedTime === 'TBD' && isUncertain) || isApproximatePickupTime ? 'text-stone-400' : 'font-bold text-stone-900'}`}>
260
+ {(formattedTime === 'TBD' && isUncertain) && (
261
+ <svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
262
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
263
+ </svg>
264
+ )}
265
+ {isApproximatePickupTime && formattedTime !== 'TBD' && (
266
+ <TooltipAnchor
267
+ showTooltip={showTooltip}
268
+ isMobile={isMobile}
269
+ onTooltipToggle={onTooltipToggle}
270
+ onTooltipShow={onTooltipShow}
271
+ />
272
+ )}
273
+ <span className="shrink-0 font-bold">{formattedTime}</span>
274
+ </span>
275
+ <span className="text-stone-400 shrink-0">-</span>
276
+ <span className="text-stone-600">
277
+ {isUncertainPickupOrDropOff ? (
278
+ <>
279
+ {t(prefixKey)}
280
+ <button
281
+ type="button"
282
+ onClick={handlePickupLocationClick}
283
+ className="text-stone-400 hover:text-stone-600 underline cursor-pointer"
284
+ >
285
+ {getDisplayLabel(locationOnlyDisplay)}
286
+ </button>
287
+ </>
288
+ ) : isUncertain ? (
289
+ <span className="text-stone-400 underline">{getDisplayLabel(itemLabel)}</span>
290
+ ) : (
291
+ getDisplayLabel(itemLabel)
292
+ )}
293
+ </span>
294
+ </span>
295
+ ) : isPlaceholder ? (
296
+ <span className="text-stone-400">{itemLabel}</span>
297
+ ) : (
298
+ <span className="inline-flex items-center gap-1.5 flex-wrap">
299
+ <span className={`inline-flex items-center gap-1.5 shrink-0 ${isUncertain ? 'text-stone-400' : 'font-bold text-stone-900'}`}>
300
+ {isUncertain && (
301
+ <svg className="w-4 h-4 shrink-0" 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" />
303
+ </svg>
304
+ )}
305
+ <span className="shrink-0 font-bold">TBD</span>
306
+ </span>
307
+ <span className="text-stone-400 shrink-0">-</span>
308
+ <span className="text-stone-600">
309
+ {isUncertainPickupOrDropOff ? (
310
+ <>
311
+ {t(prefixKey)}
312
+ <button
313
+ type="button"
314
+ onClick={handlePickupLocationClick}
315
+ className="text-stone-400 hover:text-stone-600 underline cursor-pointer"
316
+ >
317
+ {getDisplayLabel(locationOnlyDisplay)}
318
+ </button>
319
+ </>
320
+ ) : (
321
+ <span className="text-stone-400 underline">{getDisplayLabel(itemLabel)}</span>
322
+ )}
323
+ </span>
324
+ </span>
325
+ )}
326
+ </>
327
+ );
328
+
329
+ if (isItinerarySticky) {
330
+ return (
331
+ <span key={index} className={`${styles.item} ${styles.itemSticky}`}>
332
+ {content}
333
+ {index < itineraryItems.length - 1 && (
334
+ <span className={styles.separator}>
335
+ <svg className="w-3 h-3 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
336
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
337
+ </svg>
338
+ </span>
339
+ )}
340
+ </span>
341
+ );
342
+ }
343
+
344
+ return (
345
+ <div key={index} className={`${styles.item}`}>
346
+ {content}
347
+ </div>
348
+ );
349
+ })}
350
+ </div>
351
+ )}
352
+ </div>
353
+ );
354
+ }
355
+
356
+ export type ItineraryReadOnlySummaryStatus = 'loading' | 'error' | 'ready';
357
+
358
+ export type ItineraryReadOnlyPhotoPreview = {
359
+ sessionTimeRangeLabel: string;
360
+ photographerLabel: string;
361
+ sessionLengthLabel: string | null;
362
+ primaryItinerarySteps: DapItineraryStep[];
363
+ slotStartIso: string;
364
+ slotEndIso: string;
365
+ };
366
+
367
+ function formatReadOnlyItineraryTime(time: string): string {
368
+ let formattedTime = time;
369
+ if (formattedTime) {
370
+ formattedTime = formattedTime.replace(/^at /i, '');
371
+ formattedTime = formattedTime.replace(/:00(?=\s*(AM|PM))/i, '');
372
+ }
373
+ return formattedTime;
374
+ }
375
+
376
+ function ReadOnlyPhotoSessionItineraryRow({
377
+ sessionTimeRangeLabel,
378
+ photographerLabel,
379
+ sessionLengthLabel,
380
+ photoAddOnTitle,
381
+ }: {
382
+ sessionTimeRangeLabel: string;
383
+ photographerLabel: string;
384
+ sessionLengthLabel: string | null;
385
+ photoAddOnTitle: string;
386
+ }) {
387
+ const sub = ['Photographer: ' + photographerLabel, sessionLengthLabel].filter(Boolean).join(' · ');
388
+ return (
389
+ <div className={styles.item}>
390
+ <span className="inline-flex items-center gap-1.5 flex-wrap">
391
+ <span className={`inline-flex shrink-0 ${styles.readOnlyPhotoTime}`}>
392
+ {sessionTimeRangeLabel}
393
+ </span>
394
+ <span className={`shrink-0 ${styles.readOnlyPhotoDash}`}>-</span>
395
+ <span className={styles.readOnlyPhotoLabel}>
396
+ <span className="font-bold">{photoAddOnTitle}</span>
397
+ {sub ? <span>{` · ${sub}`}</span> : null}
398
+ </span>
399
+ </span>
400
+ </div>
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Same chrome and row layout as expanded [ItineraryBox] (time — label), without pickup-location
406
+ * editing or tooltips. Used for DAP “your current itinerary” under the booking reference field.
407
+ */
408
+ export function ItineraryReadOnlySummary({
409
+ title,
410
+ dateSubtitle,
411
+ itineraryItems,
412
+ status,
413
+ loadingMessage,
414
+ errorMessage,
415
+ emptyMessage,
416
+ t,
417
+ className,
418
+ titleClassName,
419
+ sticky,
420
+ photoSessionPreview,
421
+ }: {
422
+ title: string;
423
+ /** Inline after the title, e.g. `MMM d` like main [BookingFlow] itinerary. */
424
+ dateSubtitle?: string;
425
+ itineraryItems: ItineraryDisplayStep[] | null;
426
+ status: ItineraryReadOnlySummaryStatus;
427
+ loadingMessage: string;
428
+ errorMessage: string;
429
+ emptyMessage: string;
430
+ t: TranslationFn;
431
+ className?: string;
432
+ titleClassName?: string;
433
+ /** Sticks to top of the dialog content scroll area (e.g. DAP). */
434
+ sticky?: boolean;
435
+ /** When set and status is ready, inserts one green photo row into the same list layout. */
436
+ photoSessionPreview?: ItineraryReadOnlyPhotoPreview | null;
437
+ }) {
438
+ const showPhotoPreview = status === 'ready' && photoSessionPreview != null;
439
+ const stepsForInsert = showPhotoPreview ? photoSessionPreview.primaryItinerarySteps : null;
440
+ const insertAt =
441
+ stepsForInsert && stepsForInsert.length > 0 && photoSessionPreview
442
+ ? getPhotoSessionInsertPosition(stepsForInsert, {
443
+ slotStartIso: photoSessionPreview.slotStartIso,
444
+ slotEndIso: photoSessionPreview.slotEndIso,
445
+ })
446
+ : 0;
447
+
448
+ const hasItineraryRows = Boolean(itineraryItems && itineraryItems.length > 0);
449
+ const photoAddOnTitle = t('booking.dapPhotoSessionAddOn');
450
+
451
+ const boxLayoutClass = sticky ? styles.boxStickyInDialog : styles.boxEmbedded;
452
+
453
+ return (
454
+ <div
455
+ className={`${styles.box} ${styles.boxExpanded} ${boxLayoutClass} ${className ?? ''}`}
456
+ aria-busy={status === 'loading'}
457
+ >
458
+ <h3 className={`${styles.title} ${styles.titleExpanded} ${titleClassName ?? ''}`}>
459
+ {title}
460
+ {dateSubtitle ? (
461
+ <span className={styles.dateSubtitle}> · {dateSubtitle}</span>
462
+ ) : null}
463
+ </h3>
464
+ {status === 'loading' ? (
465
+ <p className={styles.readOnlySummaryMessage} aria-live="polite">
466
+ {loadingMessage}
467
+ </p>
468
+ ) : null}
469
+ {status === 'error' ? (
470
+ <p className={styles.readOnlySummaryError} role="alert">
471
+ {errorMessage}
472
+ </p>
473
+ ) : null}
474
+ {showPhotoPreview && hasItineraryRows && photoSessionPreview && itineraryItems ? (
475
+ <div className={styles.itemsExpanded}>
476
+ {itineraryItems.map((item, index) => {
477
+ const itemLabel = getStepLabel(item, t);
478
+ const formattedTime = formatReadOnlyItineraryTime(item.time ?? '');
479
+ return (
480
+ <Fragment key={`dap-itin-${index}-${item.time ?? ''}-${item.place ?? ''}`}>
481
+ {insertAt === index ? (
482
+ <ReadOnlyPhotoSessionItineraryRow
483
+ sessionTimeRangeLabel={photoSessionPreview.sessionTimeRangeLabel}
484
+ photographerLabel={photoSessionPreview.photographerLabel}
485
+ sessionLengthLabel={photoSessionPreview.sessionLengthLabel}
486
+ photoAddOnTitle={photoAddOnTitle}
487
+ />
488
+ ) : null}
489
+ <div className={styles.item}>
490
+ <span className="inline-flex items-center gap-1.5 flex-wrap">
491
+ <span className="inline-flex shrink-0 font-bold text-stone-900">
492
+ {formattedTime || '—'}
493
+ </span>
494
+ <span className="text-stone-400 shrink-0">-</span>
495
+ <span className="text-stone-600">{itemLabel}</span>
496
+ </span>
497
+ </div>
498
+ </Fragment>
499
+ );
500
+ })}
501
+ {insertAt === itineraryItems.length ? (
502
+ <ReadOnlyPhotoSessionItineraryRow
503
+ sessionTimeRangeLabel={photoSessionPreview.sessionTimeRangeLabel}
504
+ photographerLabel={photoSessionPreview.photographerLabel}
505
+ sessionLengthLabel={photoSessionPreview.sessionLengthLabel}
506
+ photoAddOnTitle={photoAddOnTitle}
507
+ />
508
+ ) : null}
509
+ </div>
510
+ ) : null}
511
+ {showPhotoPreview && !hasItineraryRows && photoSessionPreview ? (
512
+ <>
513
+ <ReadOnlyPhotoSessionItineraryRow
514
+ sessionTimeRangeLabel={photoSessionPreview.sessionTimeRangeLabel}
515
+ photographerLabel={photoSessionPreview.photographerLabel}
516
+ sessionLengthLabel={photoSessionPreview.sessionLengthLabel}
517
+ photoAddOnTitle={photoAddOnTitle}
518
+ />
519
+ <p className={styles.readOnlyPhotoFallbackFoot}>
520
+ {t('booking.dapItineraryFallbackFoot')}
521
+ </p>
522
+ </>
523
+ ) : null}
524
+ {status === 'ready' && !showPhotoPreview && itineraryItems && itineraryItems.length > 0 ? (
525
+ <div className={styles.itemsExpanded}>
526
+ {itineraryItems.map((item, index) => {
527
+ const itemLabel = getStepLabel(item, t);
528
+ const formattedTime = formatReadOnlyItineraryTime(item.time ?? '');
529
+ return (
530
+ <div key={index} className={styles.item}>
531
+ <span className="inline-flex items-center gap-1.5 flex-wrap">
532
+ <span className="inline-flex shrink-0 font-bold text-stone-900">
533
+ {formattedTime || '—'}
534
+ </span>
535
+ <span className="text-stone-400 shrink-0">-</span>
536
+ <span className="text-stone-600">{itemLabel}</span>
537
+ </span>
538
+ </div>
539
+ );
540
+ })}
541
+ </div>
542
+ ) : null}
543
+ {status === 'ready' &&
544
+ !showPhotoPreview &&
545
+ (!itineraryItems || itineraryItems.length === 0) ? (
546
+ <p className={styles.readOnlySummaryMessage}>{emptyMessage}</p>
547
+ ) : null}
548
+ </div>
549
+ );
550
+ }
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import type { ItineraryBuilderDestination } from '@/lib/booking-api';
4
+
5
+ interface ItineraryBuilderProps {
6
+ /** Shared destinations from product.itineraryBuilder */
7
+ destinations: ItineraryBuilderDestination[];
8
+ /** IDs blacklisted for this option (e.g. takkakaw_falls for Sunrise) */
9
+ optionBlacklist: string[];
10
+ /** Selected destination IDs (in order) */
11
+ selectedDestinationIds: string[];
12
+ /** Planning notes text */
13
+ planningNotes: string;
14
+ onDestinationsChange: (ids: string[]) => void;
15
+ onPlanningNotesChange: (value: string) => void;
16
+ }
17
+
18
+ export function ItineraryBuilder({
19
+ destinations,
20
+ optionBlacklist,
21
+ selectedDestinationIds,
22
+ planningNotes,
23
+ onDestinationsChange,
24
+ onPlanningNotesChange,
25
+ }: ItineraryBuilderProps) {
26
+ const availableDestinations = destinations.filter((d) => !optionBlacklist.includes(d.id));
27
+
28
+ const toggleDestination = (id: string) => {
29
+ if (selectedDestinationIds.includes(id)) {
30
+ onDestinationsChange(selectedDestinationIds.filter((d) => d !== id));
31
+ } else {
32
+ onDestinationsChange([...selectedDestinationIds, id]);
33
+ }
34
+ };
35
+
36
+ return (
37
+ <div className="space-y-6">
38
+ <div>
39
+ <label className="block text-sm font-medium text-stone-700 mb-2">
40
+ Where would you like to go?
41
+ </label>
42
+ <p className="text-sm text-stone-500 mb-3">
43
+ Select the destinations you&apos;d like to include. Our team will craft your final
44
+ itinerary.
45
+ </p>
46
+ <div className="flex flex-wrap gap-2">
47
+ {availableDestinations.map((dest) => {
48
+ const isSelected = selectedDestinationIds.includes(dest.id);
49
+ return (
50
+ <button
51
+ key={dest.id}
52
+ type="button"
53
+ onClick={() => toggleDestination(dest.id)}
54
+ className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
55
+ isSelected
56
+ ? 'bg-emerald-600 text-white'
57
+ : 'bg-stone-100 text-stone-700 hover:bg-stone-200'
58
+ }`}
59
+ >
60
+ {dest.label}
61
+ </button>
62
+ );
63
+ })}
64
+ </div>
65
+ </div>
66
+
67
+ {/* Planning notes */}
68
+ <div>
69
+ <label className="block text-sm font-medium text-stone-700 mb-2">
70
+ Planning notes (optional)
71
+ </label>
72
+ <textarea
73
+ value={planningNotes}
74
+ onChange={(e) => onPlanningNotesChange(e.target.value)}
75
+ placeholder="Any special requests, timing preferences, or things you'd like our team to know..."
76
+ rows={3}
77
+ className="w-full px-4 py-3 rounded-lg border border-stone-300 focus:outline-none focus:border-stone-500 text-stone-900 placeholder:text-stone-400"
78
+ />
79
+ </div>
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Itinerary placeholder - shown when date selected but no time yet
3
+ */
4
+
5
+ .box {
6
+ position: sticky;
7
+ top: 0;
8
+ z-index: 10;
9
+ margin-top: 1rem;
10
+ margin-bottom: 1rem;
11
+ padding: 0.75rem;
12
+ background: var(--light-orange-background-dark);
13
+ border-radius: 0.5rem;
14
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
15
+ border: 1px solid var(--booking-stone-200, #e7e5e4);
16
+ }
17
+
18
+ .title {
19
+ font-family: 'Poppins', sans-serif;
20
+ font-size: 1.125rem;
21
+ font-weight: 700;
22
+ text-transform: lowercase;
23
+ color: var(--accent-orange);
24
+ display: flex;
25
+ align-items: center;
26
+ margin-bottom: 0.5rem;
27
+ }
28
+
29
+ @media (min-width: 768px) {
30
+ .title {
31
+ font-size: 1.375rem;
32
+ }
33
+ }
34
+
35
+ .dateSubtitle {
36
+ font-weight: 400;
37
+ color: var(--accent-orange);
38
+ font-size: inherit;
39
+ opacity: 0.9;
40
+ }
41
+
42
+ .hint {
43
+ font-size: 0.875rem;
44
+ color: var(--booking-stone-600, #57534e);
45
+ }