@ticketboothapp/booking 0.1.10 → 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 +344 -34
  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,434 @@
1
+ /**
2
+ * TicketBooth dependent add-on (DAP) APIs — booking-reference-first flow.
3
+ *
4
+ * Availability response may include primary booking itinerary (no last name typed by user):
5
+ * `data.itineraryDisplay` or `data.primaryItineraryDisplay`
6
+ * and `data.primaryCustomerLastName` when stored on the booking.
7
+ * Same step shape as public booking `itineraryDisplay` (stepType, time, place, label).
8
+ *
9
+ * Itinerary-aware slots (client + recommended server behavior):
10
+ * For each `arrive` → next `depart` pair, treat [arriveTime, departTime] as an on-site
11
+ * window. Only offer slots fully contained in at least one window for the trip day.
12
+ * This module filters the GET response when an itinerary is present (see
13
+ * `filterSlotsByPrimaryItineraryWindows`). TicketBooth should apply the same rules when
14
+ * building offerings so capacity and pricing stay authoritative.
15
+ */
16
+
17
+ import { ENV } from '@/lib/env';
18
+ import {
19
+ filterDapSlotsToPrimaryItineraryWindows,
20
+ normalizeDapItinerarySteps,
21
+ type DapItineraryStep,
22
+ } from '@/lib/dap-itinerary-preview';
23
+
24
+ export type { DapItineraryStep };
25
+
26
+ const API_BASE = ENV.API_URL;
27
+
28
+ function getAuthHeaders(): Record<string, string> {
29
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
30
+ if (ENV.BASIC_AUTH) {
31
+ headers['Authorization'] = `Basic ${ENV.BASIC_AUTH}`;
32
+ }
33
+ return headers;
34
+ }
35
+
36
+ async function parseJsonSafely(res: Response): Promise<unknown> {
37
+ try {
38
+ return await res.json();
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ interface ApiErrorPayload {
45
+ errorCode?: string;
46
+ errorMessage?: string;
47
+ error?: string;
48
+ }
49
+
50
+ function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
51
+ return typeof value === 'object' && value !== null;
52
+ }
53
+
54
+ function messageFromPayload(payload: unknown, fallback: string): string {
55
+ if (isApiErrorPayload(payload)) {
56
+ return payload.errorMessage || payload.error || fallback;
57
+ }
58
+ return fallback;
59
+ }
60
+
61
+ function pickStr(obj: Record<string, unknown>, ...keys: string[]): string | undefined {
62
+ for (const k of keys) {
63
+ const v = obj[k];
64
+ if (typeof v === 'string' && v.trim()) return v.trim();
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ function pickNum(obj: Record<string, unknown>, ...keys: string[]): number | undefined {
70
+ for (const k of keys) {
71
+ const v = obj[k];
72
+ if (typeof v === 'number' && !Number.isNaN(v)) return v;
73
+ if (typeof v === 'string' && v.trim()) {
74
+ const n = Number(v);
75
+ if (!Number.isNaN(n)) return n;
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** Positive whole days, capped for sanity (DAP cancellation window from TicketBooth). */
82
+ function pickPositiveDayCount(obj: Record<string, unknown>, ...keys: string[]): number | undefined {
83
+ const n = pickNum(obj, ...keys);
84
+ if (n === undefined || !Number.isFinite(n)) return undefined;
85
+ const floor = Math.floor(n);
86
+ if (floor < 1) return undefined;
87
+ return Math.min(floor, 365);
88
+ }
89
+
90
+ function extractDapCancellationDaysBeforeSession(
91
+ inner: Record<string, unknown> | null | undefined
92
+ ): number | undefined {
93
+ if (!inner) return undefined;
94
+ const top = pickPositiveDayCount(
95
+ inner,
96
+ 'cancellationDaysBeforeSession',
97
+ 'cancellation_days_before_session'
98
+ );
99
+ if (top !== undefined) return top;
100
+ const prod =
101
+ inner.dependentAddOnProduct ?? inner.dependent_add_on_product ?? inner.product;
102
+ if (prod && typeof prod === 'object') {
103
+ return pickPositiveDayCount(
104
+ prod as Record<string, unknown>,
105
+ 'cancellationDaysBeforeSession',
106
+ 'cancellation_days_before_session'
107
+ );
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ /** Normalize TicketBooth create-payment-intent JSON (nested `data`, snake_case). */
113
+ function coercePaymentIntentResult(payload: unknown): CreateDependentAddOnPaymentIntentResult {
114
+ const root = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
115
+ const inner =
116
+ root.data && typeof root.data === 'object'
117
+ ? (root.data as Record<string, unknown>)
118
+ : root;
119
+ return {
120
+ clientSecret: pickStr(inner, 'clientSecret', 'client_secret'),
121
+ totalAmount: pickNum(inner, 'totalAmount', 'total_amount'),
122
+ currency: pickStr(inner, 'currency'),
123
+ subtotalAmount: pickNum(inner, 'subtotalAmount', 'subtotal_amount'),
124
+ taxAmount: pickNum(inner, 'taxAmount', 'tax_amount'),
125
+ customerLastName: pickStr(inner, 'customerLastName', 'customer_last_name'),
126
+ };
127
+ }
128
+
129
+ export type DependentAddOnCheckoutQuestionType = 'TEXT' | 'CHECKBOX';
130
+
131
+ export interface DependentAddOnCheckoutQuestion {
132
+ id: string;
133
+ type: DependentAddOnCheckoutQuestionType;
134
+ label: string;
135
+ required?: boolean;
136
+ maxLength?: number | null;
137
+ placeholder?: string | null;
138
+ helpText?: string | null;
139
+ }
140
+
141
+ export interface DependentAddOnSlot {
142
+ offeringId: string;
143
+ resourceId: string;
144
+ resourceName?: string;
145
+ slotStart: string;
146
+ slotEnd: string;
147
+ capacityTotal?: number;
148
+ capacityRemaining?: number;
149
+ price?: number;
150
+ currency?: string;
151
+ dependentAddOnProductOptionId?: string;
152
+ }
153
+
154
+ export interface GetDependentAddOnAvailabilityParams {
155
+ companyId: string;
156
+ primaryBookingReference: string;
157
+ lastName: string;
158
+ dependentAddOnProductId: string;
159
+ dependentAddOnProductOptionId?: string;
160
+ /** YYYY-MM-DD — limits slots to one local day when supported */
161
+ date?: string;
162
+ /**
163
+ * When true (default), drop slots not fully inside an arrive→depart on-site window
164
+ * derived from `primaryItineraryDisplay` in the same response (or after normalize).
165
+ */
166
+ filterSlotsByPrimaryItineraryWindows?: boolean;
167
+ /** Pass from AbortController to cancel in-flight work (e.g. route change, Strict Mode remount). */
168
+ signal?: AbortSignal;
169
+ }
170
+
171
+ export type DependentAddOnBookingUpsellProductRow = {
172
+ dependentAddOnProductId: string;
173
+ dependentAddOnProductOptionId?: string;
174
+ };
175
+
176
+ export interface GetDependentAddOnBookingUpsellEligibilityParams {
177
+ companyId: string;
178
+ primaryBookingReference: string;
179
+ lastName: string;
180
+ signal?: AbortSignal;
181
+ }
182
+
183
+ /**
184
+ * Single TicketBooth read for manage-booking photo upsell: ACTIVE DAP products that have at least one
185
+ * bookable slot for this primary booking (same last-name gate as availability). Replaces N parallel
186
+ * `getDependentAddOnAvailability` probes.
187
+ */
188
+ export async function getDependentAddOnBookingUpsellEligibility(
189
+ params: GetDependentAddOnBookingUpsellEligibilityParams
190
+ ): Promise<{ productsWithSlots: DependentAddOnBookingUpsellProductRow[] }> {
191
+ const endpoint = '/1/dependent-add-ons/booking-upsell-eligibility';
192
+ const q = new URLSearchParams({
193
+ companyId: params.companyId,
194
+ primaryBookingReference: params.primaryBookingReference.trim(),
195
+ lastName: params.lastName.trim(),
196
+ });
197
+ const url = `${API_BASE}${endpoint}?${q}`;
198
+ if (params.signal?.aborted) {
199
+ const aborted = new Error('Aborted');
200
+ aborted.name = 'AbortError';
201
+ throw aborted;
202
+ }
203
+ let res: Response;
204
+ try {
205
+ res = await fetch(url, {
206
+ method: 'GET',
207
+ headers: getAuthHeaders(),
208
+ signal: params.signal,
209
+ });
210
+ } catch (err) {
211
+ if (err instanceof Error && err.name === 'AbortError') throw err;
212
+ if (
213
+ typeof DOMException !== 'undefined' &&
214
+ err instanceof DOMException &&
215
+ err.name === 'AbortError'
216
+ ) {
217
+ throw err;
218
+ }
219
+ const msg = err instanceof Error ? err.message : String(err);
220
+ throw new Error(`Unable to load add-on upsell eligibility. ${msg}`);
221
+ }
222
+ const payload = await parseJsonSafely(res);
223
+ if (!res.ok) {
224
+ throw new Error(
225
+ messageFromPayload(payload, `Could not load upsell eligibility (HTTP ${res.status})`)
226
+ );
227
+ }
228
+ const data = payload as { data?: Record<string, unknown> } | null;
229
+ const inner = data?.data;
230
+ const rawList = inner?.productsWithSlots ?? inner?.products_with_slots;
231
+ const rows: DependentAddOnBookingUpsellProductRow[] = [];
232
+ if (Array.isArray(rawList)) {
233
+ for (const item of rawList) {
234
+ if (!item || typeof item !== 'object') continue;
235
+ const o = item as Record<string, unknown>;
236
+ const id =
237
+ pickStr(o, 'dependentAddOnProductId', 'dependent_add_on_product_id') ?? '';
238
+ if (!id) continue;
239
+ const opt = pickStr(
240
+ o,
241
+ 'dependentAddOnProductOptionId',
242
+ 'dependent_add_on_product_option_id'
243
+ );
244
+ rows.push({
245
+ dependentAddOnProductId: id,
246
+ ...(opt ? { dependentAddOnProductOptionId: opt } : {}),
247
+ });
248
+ }
249
+ }
250
+ return { productsWithSlots: rows };
251
+ }
252
+
253
+ export type DependentAddOnAvailabilityResult = {
254
+ slots: DependentAddOnSlot[];
255
+ /** Present when API includes itinerary for this reference (preview without last name). */
256
+ primaryItineraryDisplay: DapItineraryStep[];
257
+ /** Primary booking last name from TicketBooth (for manage-booking after DAP payment). */
258
+ primaryCustomerLastName?: string;
259
+ /** When TicketBooth sends it on this DAP product / availability payload. */
260
+ cancellationDaysBeforeSession?: number;
261
+ /** Server-defined checkout questionnaire for this DAP (empty if none). */
262
+ checkoutQuestions: DependentAddOnCheckoutQuestion[];
263
+ };
264
+
265
+ export async function getDependentAddOnAvailability(
266
+ params: GetDependentAddOnAvailabilityParams
267
+ ): Promise<DependentAddOnAvailabilityResult> {
268
+ const endpoint = '/1/dependent-add-ons/availability';
269
+ const q = new URLSearchParams({
270
+ companyId: params.companyId,
271
+ primaryBookingReference: params.primaryBookingReference.trim(),
272
+ lastName: params.lastName.trim(),
273
+ dependentAddOnProductId: params.dependentAddOnProductId,
274
+ });
275
+ if (params.dependentAddOnProductOptionId?.trim()) {
276
+ q.set('dependentAddOnProductOptionId', params.dependentAddOnProductOptionId.trim());
277
+ }
278
+ if (params.date?.trim()) {
279
+ q.set('date', params.date.trim());
280
+ }
281
+ const url = `${API_BASE}${endpoint}?${q}`;
282
+ if (params.signal?.aborted) {
283
+ const aborted = new Error('Aborted');
284
+ aborted.name = 'AbortError';
285
+ throw aborted;
286
+ }
287
+ let res: Response;
288
+ try {
289
+ res = await fetch(url, {
290
+ method: 'GET',
291
+ headers: getAuthHeaders(),
292
+ signal: params.signal,
293
+ });
294
+ } catch (err) {
295
+ if (err instanceof Error && err.name === 'AbortError') throw err;
296
+ if (
297
+ typeof DOMException !== 'undefined' &&
298
+ err instanceof DOMException &&
299
+ err.name === 'AbortError'
300
+ ) {
301
+ throw err;
302
+ }
303
+ const msg = err instanceof Error ? err.message : String(err);
304
+ throw new Error(`Unable to load add-on times. ${msg}`);
305
+ }
306
+ const payload = await parseJsonSafely(res);
307
+ if (!res.ok) {
308
+ throw new Error(messageFromPayload(payload, `Could not load times (HTTP ${res.status})`));
309
+ }
310
+ const data = payload as { data?: Record<string, unknown> } | null;
311
+ const inner = data?.data;
312
+ const cancellationDaysBeforeSession = extractDapCancellationDaysBeforeSession(inner);
313
+ const slots = inner?.slots;
314
+ const itineraryRaw =
315
+ inner?.itineraryDisplay ?? inner?.primaryItineraryDisplay ?? null;
316
+ const primaryCustomerLastName = inner
317
+ ? pickStr(
318
+ inner,
319
+ 'primaryCustomerLastName',
320
+ 'primary_customer_last_name'
321
+ )
322
+ : undefined;
323
+ const primaryItineraryDisplay = normalizeDapItinerarySteps(itineraryRaw);
324
+ const rawSlots = Array.isArray(slots) ? slots : [];
325
+ const shouldFilterWindows = params.filterSlotsByPrimaryItineraryWindows !== false;
326
+ const filteredSlots =
327
+ shouldFilterWindows && primaryItineraryDisplay.length > 0
328
+ ? filterDapSlotsToPrimaryItineraryWindows(rawSlots, primaryItineraryDisplay)
329
+ : rawSlots;
330
+
331
+ const checkoutQuestions = normalizeCheckoutQuestions(
332
+ inner?.checkoutQuestions ?? inner?.checkout_questions
333
+ );
334
+
335
+ return {
336
+ slots: filteredSlots,
337
+ primaryItineraryDisplay,
338
+ primaryCustomerLastName,
339
+ checkoutQuestions,
340
+ ...(cancellationDaysBeforeSession !== undefined
341
+ ? { cancellationDaysBeforeSession }
342
+ : {}),
343
+ };
344
+ }
345
+
346
+ function normalizeCheckoutQuestions(raw: unknown): DependentAddOnCheckoutQuestion[] {
347
+ if (!Array.isArray(raw)) return [];
348
+ const out: DependentAddOnCheckoutQuestion[] = [];
349
+ for (const item of raw) {
350
+ if (!item || typeof item !== 'object') continue;
351
+ const o = item as Record<string, unknown>;
352
+ const id = pickStr(o, 'id') ?? '';
353
+ if (!id) continue;
354
+ const typeRaw = (pickStr(o, 'type') ?? 'TEXT').toUpperCase();
355
+ const type: DependentAddOnCheckoutQuestionType =
356
+ typeRaw === 'CHECKBOX' ? 'CHECKBOX' : 'TEXT';
357
+ const label = pickStr(o, 'label') ?? id;
358
+ const required = Boolean(o.required ?? o.Required);
359
+ const maxLength =
360
+ typeof o.maxLength === 'number'
361
+ ? o.maxLength
362
+ : typeof o.max_length === 'number'
363
+ ? o.max_length
364
+ : undefined;
365
+ const placeholder = pickStr(o, 'placeholder', 'placeholder_text') ?? null;
366
+ const helpText = pickStr(o, 'helpText', 'help_text') ?? null;
367
+ out.push({
368
+ id,
369
+ type,
370
+ label,
371
+ required,
372
+ maxLength: maxLength ?? null,
373
+ placeholder,
374
+ helpText,
375
+ });
376
+ }
377
+ return out;
378
+ }
379
+
380
+ export interface CreateDependentAddOnPaymentIntentBody {
381
+ companyId: string;
382
+ primaryBookingReference: string;
383
+ lastName: string;
384
+ dependentAddOnProductId: string;
385
+ dependentAddOnProductOptionId?: string;
386
+ offeringId: string;
387
+ slotStart: string;
388
+ slotEnd: string;
389
+ quantity: number;
390
+ idempotencyKey: string;
391
+ customerEmail?: string;
392
+ customerFirstName?: string;
393
+ customerLastName?: string;
394
+ /** Keyed by question id; checkboxes use "true" / "false". */
395
+ checkoutAnswers?: Record<string, string>;
396
+ }
397
+
398
+ export interface CreateDependentAddOnPaymentIntentResult {
399
+ clientSecret?: string;
400
+ totalAmount?: number;
401
+ currency?: string;
402
+ /** Pre-tax subtotal (same as TicketBooth pricing config + GST logic) */
403
+ subtotalAmount?: number;
404
+ /** GST / tax included in totalAmount for CAD, etc. */
405
+ taxAmount?: number;
406
+ /** Primary booking customer last name (for manage-booking redirect). */
407
+ customerLastName?: string;
408
+ }
409
+
410
+ /**
411
+ * Server-priced Stripe PaymentIntent for a dependent add-on. After payment succeeds,
412
+ * TicketBooth webhook creates the dependent add-on booking (same idempotency key).
413
+ */
414
+ export async function createDependentAddOnPaymentIntent(
415
+ body: CreateDependentAddOnPaymentIntentBody
416
+ ): Promise<CreateDependentAddOnPaymentIntentResult> {
417
+ const endpoint = '/checkout/dependent-add-on-payment-intent';
418
+ let res: Response;
419
+ try {
420
+ res = await fetch(`${API_BASE}${endpoint}`, {
421
+ method: 'POST',
422
+ headers: getAuthHeaders(),
423
+ body: JSON.stringify(body),
424
+ });
425
+ } catch (err) {
426
+ const msg = err instanceof Error ? err.message : String(err);
427
+ throw new Error(`Unable to start payment. ${msg}`);
428
+ }
429
+ const payload = await parseJsonSafely(res);
430
+ if (!res.ok) {
431
+ throw new Error(messageFromPayload(payload, `Could not start payment (HTTP ${res.status})`));
432
+ }
433
+ return coercePaymentIntentResult(payload);
434
+ }
package/src/lib/env.ts CHANGED
@@ -3,10 +3,94 @@
3
3
  * Direct access to process.env for Next.js build-time replacement
4
4
  * This ensures NEXT_PUBLIC_* variables are properly embedded at build time
5
5
  */
6
+ const getApiUrl = (): string => {
7
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL;
8
+
9
+ if (!apiUrl) {
10
+ if (process.env.NODE_ENV === 'production') {
11
+ throw new Error(
12
+ 'NEXT_PUBLIC_API_URL environment variable is required for production builds. ' +
13
+ 'Set it in your .env.local file or build environment.'
14
+ );
15
+ }
16
+ // Development fallback - warn but allow
17
+ console.warn(
18
+ '⚠️ NEXT_PUBLIC_API_URL not set. Using localhost fallback. ' +
19
+ 'Set NEXT_PUBLIC_API_URL in .env.local for production API.'
20
+ );
21
+ return 'http://localhost:3001';
22
+ }
23
+
24
+ return apiUrl;
25
+ };
26
+
27
+ const getGoogleMapsApiKey = (): string => {
28
+ return process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? '';
29
+ };
30
+
31
+ const getStripePublishableKey = (): string => {
32
+ const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? '';
33
+ return key;
34
+ };
35
+
36
+ /**
37
+ * Base64-encoded `user:password` for TicketBooth Basic auth (not including the "Basic " prefix).
38
+ * Some endpoints (e.g. get-availabilities, reserve) require this; others may work without it.
39
+ */
40
+ const getBasicAuth = (): string => {
41
+ return process.env.NEXT_PUBLIC_BASIC_AUTH ?? '';
42
+ };
43
+
44
+ /** Via Via company ID in TicketBooth (for API calls). */
45
+ const getCompanyId = (): string => {
46
+ return process.env.NEXT_PUBLIC_COMPANY_ID ?? 'c_LFU0Vx9hS5v3';
47
+ };
48
+
49
+ /** GA4 Measurement ID (e.g. G-XXXXXXXXXX). Use same as backend for deduplication. */
50
+ const getGa4MeasurementId = (): string => {
51
+ return process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID ?? 'G-BVCCYBSHP8';
52
+ };
53
+
54
+ /** Meta Pixel ID (e.g. 123456789012345). Use same as backend for deduplication. */
55
+ const getMetaPixelId = (): string => {
56
+ return process.env.NEXT_PUBLIC_META_PIXEL_ID ?? '';
57
+ };
58
+
59
+ /** App environment: development, staging, or production. Used for analytics, feature flags, etc. */
60
+ export type AppEnv = 'development' | 'staging' | 'production';
61
+
62
+ const getAppEnv = (): AppEnv => {
63
+ const raw = process.env.NEXT_PUBLIC_APP_ENV?.toLowerCase();
64
+ if (raw === 'production' || raw === 'staging') return raw;
65
+ return 'development';
66
+ };
67
+
68
+ export const APP_ENV = getAppEnv();
69
+
70
+ /** True when running in production. Use for GA4, Meta Pixel, etc. */
71
+ export const isProduction = (): boolean => APP_ENV === 'production';
72
+
73
+ /** True when running in staging. */
74
+ export const isStaging = (): boolean => APP_ENV === 'staging';
75
+
76
+ /** True when running in development (local or when NEXT_PUBLIC_APP_ENV is unset). */
77
+ export const isDevelopment = (): boolean => APP_ENV === 'development';
78
+
79
+ /** True when purchase events should only log to console (dev/staging). Phase 2: production sends to GA4/Meta. */
80
+ export const shouldLogPurchaseToConsole = (): boolean =>
81
+ APP_ENV === 'development' || APP_ENV === 'staging';
82
+
83
+ /** True when running on localhost (runtime check). Use to avoid sending to GA4/Meta when testing prod build locally. */
84
+ export const isLocalhost = (): boolean =>
85
+ typeof window !== 'undefined' &&
86
+ (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
87
+
6
88
  export const ENV = {
7
- API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
8
- BASIC_AUTH: process.env.NEXT_PUBLIC_BASIC_AUTH || '', // Optional - empty string is valid
9
- COMPANY_ID: process.env.NEXT_PUBLIC_COMPANY_ID || 'c_LFU0Vx9hS5v3',
10
- GOOGLE_MAPS_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || '', // Optional - empty string is valid
11
- STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || '', // Required for embedded checkout modal
89
+ API_URL: getApiUrl(),
90
+ GOOGLE_MAPS_API_KEY: getGoogleMapsApiKey(),
91
+ STRIPE_PUBLISHABLE_KEY: getStripePublishableKey(),
92
+ BASIC_AUTH: getBasicAuth(),
93
+ COMPANY_ID: getCompanyId(),
94
+ GA4_MEASUREMENT_ID: getGa4MeasurementId(),
95
+ META_PIXEL_ID: getMetaPixelId(),
12
96
  } as const;
@@ -0,0 +1,20 @@
1
+ import { initializeApp } from 'firebase/app';
2
+ import { getFirestore } from 'firebase/firestore';
3
+
4
+ // Your web app's Firebase configuration
5
+ const firebaseConfig = {
6
+ apiKey: "AIzaSyDYHq_ytlzlWxByKC8ryisZvSujtviEolY",
7
+ authDomain: "viavia-website.firebaseapp.com",
8
+ projectId: "viavia-website",
9
+ storageBucket: "viavia-website.firebasestorage.app",
10
+ messagingSenderId: "114849053171",
11
+ appId: "1:114849053171:web:0f9d0412a13e521284ba86"
12
+ };
13
+
14
+ // Initialize Firebase
15
+ const app = initializeApp(firebaseConfig);
16
+
17
+ // Initialize Firestore
18
+ export const db = getFirestore(app);
19
+
20
+ export default app;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * API helpers for job applications (careers page)
3
+ */
4
+
5
+ import { ENV } from '@/lib/env';
6
+
7
+ const API_BASE = ENV.API_URL;
8
+
9
+ // Via Via company ID (from ticketbooth config)
10
+ export const VIAVIA_COMPANY_ID = 'c_LFU0Vx9hS5v3';
11
+
12
+ export interface PresignedUploadResult {
13
+ uploadUrl: string;
14
+ s3Key: string;
15
+ contentType: string;
16
+ }
17
+
18
+ export async function getPresignedUploadUrl(
19
+ jobId: string,
20
+ fileType: 'resume' | 'coverLetter',
21
+ fileName: string
22
+ ): Promise<PresignedUploadResult> {
23
+ const res = await fetch(`${API_BASE}/1/public/job-applications/upload-url`, {
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json' },
26
+ body: JSON.stringify({
27
+ jobId,
28
+ companyId: VIAVIA_COMPANY_ID,
29
+ fileType: fileType === 'coverLetter' ? 'coverLetter' : 'resume',
30
+ fileName,
31
+ }),
32
+ });
33
+ if (!res.ok) {
34
+ const err = await res.json();
35
+ throw new Error(err.message || err.error || 'Failed to get upload URL');
36
+ }
37
+ const data = await res.json();
38
+ return data.data;
39
+ }
40
+
41
+ export async function uploadFileToPresignedUrl(
42
+ url: string,
43
+ file: File,
44
+ contentType: string
45
+ ): Promise<void> {
46
+ const res = await fetch(url, {
47
+ method: 'PUT',
48
+ headers: { 'Content-Type': contentType },
49
+ body: file,
50
+ });
51
+ if (!res.ok) {
52
+ throw new Error('Failed to upload file');
53
+ }
54
+ }
55
+
56
+ export interface SubmitApplicationPayload {
57
+ jobId: string;
58
+ fullName: string;
59
+ email: string;
60
+ phone?: string;
61
+ answers: Record<string, string>;
62
+ resumeS3Key?: string;
63
+ coverLetterS3Key?: string;
64
+ }
65
+
66
+ export async function submitJobApplication(
67
+ payload: SubmitApplicationPayload
68
+ ): Promise<{ applicationId: string }> {
69
+ const res = await fetch(`${API_BASE}/1/public/job-applications`, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify({
73
+ ...payload,
74
+ companyId: VIAVIA_COMPANY_ID,
75
+ }),
76
+ });
77
+ if (!res.ok) {
78
+ const err = await res.json();
79
+ throw new Error(err.message || err.error || 'Failed to submit application');
80
+ }
81
+ const data = await res.json();
82
+ return data.data;
83
+ }
@@ -0,0 +1,16 @@
1
+ /** Partner portal embed → /manage-booking iframe: trigger the iframe's print dialog. */
2
+ export const MANAGE_BOOKING_EMBED_PRINT_MSG = 'viavia:manage-booking-print' as const;
3
+ export const MANAGE_BOOKING_EMBED_PRINT_V = 1 as const;
4
+
5
+ export type ManageBookingEmbedPrintMessage = {
6
+ type: typeof MANAGE_BOOKING_EMBED_PRINT_MSG;
7
+ v: typeof MANAGE_BOOKING_EMBED_PRINT_V;
8
+ };
9
+
10
+ export function isManageBookingEmbedPrintMessage(
11
+ data: unknown,
12
+ ): data is ManageBookingEmbedPrintMessage {
13
+ if (typeof data !== 'object' || data === null) return false;
14
+ const o = data as Record<string, unknown>;
15
+ return o.type === MANAGE_BOOKING_EMBED_PRINT_MSG && o.v === MANAGE_BOOKING_EMBED_PRINT_V;
16
+ }