@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.
- package/package.json +1 -1
- package/src/colours.css +23 -0
- package/src/components/BookingDetails.module.css +1591 -0
- package/src/components/BookingDetails.tsx +2072 -354
- package/src/components/BookingWidget.tsx +28 -248
- package/src/components/JobApplicationDialog.module.css +440 -0
- package/src/components/JobApplicationDialog.tsx +620 -0
- package/src/components/ManageBookingView.tsx +28 -36
- package/src/components/PhoneInputWithCountry.module.css +131 -0
- package/src/components/PhoneInputWithCountry.tsx +44 -0
- package/src/components/PickupLocationDialog.module.css +360 -0
- package/src/components/PickupLocationDialog.tsx +357 -0
- package/src/components/PickupLocationMap.tsx +110 -0
- package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
- package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
- package/src/components/accordion.css +27 -0
- package/src/components/accordion.tsx +29 -0
- package/src/components/analytics/AnalyticsConsentRestore.tsx +19 -0
- package/src/components/analytics/AnalyticsScripts.tsx +106 -0
- package/src/components/analytics/CookieConsentBanner.css +86 -0
- package/src/components/analytics/CookieConsentBanner.tsx +102 -0
- package/src/components/booking/AddOnsSection.module.css +10 -0
- package/src/components/booking/AddOnsSection.tsx +184 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
- package/src/components/booking/BookingDialog.module.css +643 -0
- package/src/components/booking/BookingDialog.tsx +356 -0
- package/src/components/booking/BookingFlow.tsx +4385 -0
- package/src/components/booking/BookingFlowCollage.module.css +148 -0
- package/src/components/booking/BookingFlowCollage.tsx +184 -0
- package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
- package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
- package/src/components/booking/BookingFlowPreview.tsx +51 -0
- package/src/components/booking/BookingProductGrid.module.css +359 -0
- package/src/components/booking/BookingProductGrid.tsx +497 -0
- package/src/components/booking/Calendar.module.css +616 -0
- package/src/components/{Calendar.tsx → booking/Calendar.tsx} +464 -247
- package/src/components/booking/CancellationPolicySelector.module.css +124 -0
- package/src/components/booking/CancellationPolicySelector.tsx +142 -0
- package/src/components/booking/ChangeBookingDialog.tsx +562 -0
- package/src/components/booking/CheckoutForm.module.css +244 -0
- package/src/components/booking/CheckoutForm.tsx +364 -0
- package/src/components/{CheckoutModal.tsx → booking/CheckoutModal.tsx} +176 -19
- package/src/components/booking/DapFlowCollage.tsx +88 -0
- package/src/components/booking/DapTourDescription.tsx +35 -0
- package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
- package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
- package/src/components/booking/InfoTooltip.tsx +108 -0
- package/src/components/booking/ItineraryBox.module.css +258 -0
- package/src/components/booking/ItineraryBox.tsx +550 -0
- package/src/components/{ItineraryBuilder.tsx → booking/ItineraryBuilder.tsx} +1 -2
- package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
- package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
- package/src/components/{MealDrinkAddOnSelector.tsx → booking/MealDrinkAddOnSelector.tsx} +21 -13
- package/src/components/booking/PickupLocationSelector.module.css +124 -0
- package/src/components/{PickupLocationSelector.tsx → booking/PickupLocationSelector.tsx} +315 -290
- package/src/components/booking/PickupTimeSelector.module.css +134 -0
- package/src/components/booking/PickupTimeSelector.tsx +112 -0
- package/src/components/{PriceBreakdown.tsx → booking/PriceBreakdown.tsx} +3 -3
- package/src/components/{PriceSummary.tsx → booking/PriceSummary.tsx} +51 -28
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
- package/src/components/booking/PromoCodeInput.module.css +166 -0
- package/src/components/booking/PromoCodeInput.tsx +99 -0
- package/src/components/booking/ReturnTimeSelector.module.css +173 -0
- package/src/components/booking/ReturnTimeSelector.tsx +145 -0
- package/src/components/{TermsAcceptance.tsx → booking/TermsAcceptance.tsx} +9 -8
- package/src/components/booking/TicketSelector.module.css +164 -0
- package/src/components/booking/TicketSelector.tsx +199 -0
- package/src/components/booking/TourDescription.module.css +304 -0
- package/src/components/booking/TourDescription.tsx +273 -0
- package/src/components/booking/booking-flow-ui.ts +15 -1
- package/src/components/booking/booking-flow.css +944 -0
- package/src/components/bottom-sheet.module.css +78 -0
- package/src/components/bottom-sheet.tsx +60 -0
- package/src/components/breadcrumb.module.css +40 -0
- package/src/components/breadcrumb.tsx +36 -0
- package/src/components/button.css +245 -0
- package/src/components/button.tsx +152 -0
- package/src/components/client-bottom-sheet.tsx +14 -0
- package/src/components/colorable-svg.tsx +29 -0
- package/src/components/conditional-footer.tsx +27 -0
- package/src/components/contact-us.module.css +147 -0
- package/src/components/contact-us.tsx +49 -0
- package/src/components/email-signup.css +151 -0
- package/src/components/email-signup.tsx +63 -0
- package/src/components/faq-wrapper.module.css +47 -0
- package/src/components/faq-wrapper.tsx +15 -0
- package/src/components/footer.css +187 -0
- package/src/components/footer.tsx +143 -0
- package/src/components/global-simple-modal.tsx +33 -0
- package/src/components/google-review-summary.module.css +77 -0
- package/src/components/google-review-summary.tsx +50 -0
- package/src/components/hero-image.css +13 -0
- package/src/components/hero-image.tsx +44 -0
- package/src/components/image.css +29 -0
- package/src/components/image.tsx +113 -0
- package/src/components/language-aware-link.tsx +72 -0
- package/src/components/language-switcher.module.css +124 -0
- package/src/components/language-switcher.tsx +75 -0
- package/src/components/map-section.css +59 -0
- package/src/components/map-section.tsx +63 -0
- package/src/components/navbar.module.css +152 -0
- package/src/components/navbar.tsx +125 -0
- package/src/components/parallax-provider.tsx +11 -0
- package/src/components/partner/PartnerBookingPage.module.css +130 -0
- package/src/components/partner/PartnerBookingPage.tsx +390 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +19 -35
- package/src/components/product-tag.module.css +30 -0
- package/src/components/product-tag.tsx +34 -0
- package/src/components/product-theme-pages/best-option.module.css +70 -0
- package/src/components/product-theme-pages/best-option.tsx +35 -0
- package/src/components/product-theme-pages/extended-tour-options.module.css +22 -0
- package/src/components/product-theme-pages/extended-tour-options.tsx +11 -0
- package/src/components/product-theme-pages/image-modal.tsx +248 -0
- package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
- package/src/components/product-theme-pages/photo-gallery.tsx +90 -0
- package/src/components/product-theme-pages/product-theme-page-layout.module.css +13 -0
- package/src/components/product-theme-pages/product-theme-page-layout.tsx +67 -0
- package/src/components/product-theme-pages/top-of-fold.module.css +179 -0
- package/src/components/product-theme-pages/top-of-fold.tsx +80 -0
- package/src/components/product-tile/image-only-product-tile-desktop.module.css +106 -0
- package/src/components/product-tile/image-only-product-tile-desktop.tsx +56 -0
- package/src/components/product-tile/image-only-product-tile-mobile.module.css +122 -0
- package/src/components/product-tile/image-only-product-tile-mobile.tsx +89 -0
- package/src/components/product-tile/image-only-product-tile.tsx +44 -0
- package/src/components/product-tile/product-tile-card.module.css +84 -0
- package/src/components/product-tile/product-tile-card.tsx +61 -0
- package/src/components/review-highlights-section.css +85 -0
- package/src/components/review-highlights-section.tsx +127 -0
- package/src/components/season-closure-overlay.module.css +99 -0
- package/src/components/season-closure-overlay.tsx +98 -0
- package/src/components/simple-modal.tsx +69 -0
- package/src/components/simple-top-of-fold.module.css +76 -0
- package/src/components/simple-top-of-fold.tsx +34 -0
- package/src/components/spacer.css +41 -0
- package/src/components/spacer.tsx +23 -0
- package/src/components/star-rating.module.css +74 -0
- package/src/components/star-rating.tsx +48 -0
- package/src/components/terms/TermsContent.tsx +178 -0
- package/src/components/title-subtitle.module.css +10 -0
- package/src/components/title-subtitle.tsx +30 -0
- package/src/components/translatable-reviews.tsx +75 -0
- package/src/components/value-pill.module.css +59 -0
- package/src/components/value-pill.tsx +46 -0
- package/src/components/value-props.css +185 -0
- package/src/components/value-props.tsx +88 -0
- package/src/constants/booking-guide-quiz.ts +64 -0
- package/src/constants/contact-info.ts +2 -0
- package/src/constants/faq.ts +44 -0
- package/src/constants/images.ts +556 -0
- package/src/constants/json-ld/faq-json-ld.tsx +170 -0
- package/src/constants/json-ld/homepage-json-ld.tsx +138 -0
- package/src/constants/json-ld/job-posting-json-ld.tsx +92 -0
- package/src/constants/json-ld/organization-json-ld.tsx +62 -0
- package/src/constants/json-ld/page-json-ld.tsx +6 -0
- package/src/constants/json-ld/product-json-ld.tsx +154 -0
- package/src/constants/json-ld/review-json-ld.tsx +377 -0
- package/src/constants/navigation-links/footer-links.ts +48 -0
- package/src/constants/navigation-links/nav-bar-links.ts +41 -0
- package/src/constants/navigation-links/navigation-link.ts +6 -0
- package/src/constants/pill-values.ts +210 -0
- package/src/constants/products.ts +155 -0
- package/src/constants/quiz-recommendations.ts +506 -0
- package/src/constants/reviews.ts +75 -0
- package/src/constants/staff.ts +197 -0
- package/src/constants/value-props.ts +58 -0
- package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
- package/src/data/dap-descriptions/session-elopements.en.json +60 -0
- package/src/data/dap-descriptions/session-proposals.en.json +60 -0
- package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
- package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
- package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
- package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
- package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
- package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
- package/src/data/product-descriptions/private-tour.en.json +80 -0
- package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
- package/src/data/products-config.json +101 -0
- package/src/hooks/use-bottom-sheet.tsx +15 -0
- package/src/hooks/use-simple-modal.tsx +27 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
- package/src/hooks/useEmailSubscription.tsx +103 -0
- package/src/hooks/useEmbeddedInIframe.ts +16 -0
- package/src/hooks/useIsBookingLaunchLive.ts +49 -0
- package/src/hooks/useQuiz.tsx +210 -0
- package/src/index.ts +27 -2
- package/src/lib/analytics.ts +197 -0
- package/src/lib/booking/booking-source.ts +20 -2
- package/src/lib/{checkout-breakdown.ts → booking/checkout-breakdown.ts} +1 -1
- package/src/lib/booking/correlation-id.ts +46 -0
- package/src/lib/{i18n → booking/i18n}/messages/en.json +48 -4
- package/src/lib/{i18n → booking/i18n}/messages/fr.json +48 -4
- package/src/lib/booking/itinerary-display.ts +36 -0
- package/src/lib/{itinerary-labels.ts → booking/itinerary-labels.ts} +1 -1
- package/src/lib/{location-calculations.ts → booking/location-calculations.ts} +4 -4
- package/src/lib/{location-utils.ts → booking/location-utils.ts} +26 -0
- package/src/lib/{map-utils.ts → booking/map-utils.ts} +3 -3
- package/src/lib/booking/normalize-booking-product-id.ts +7 -0
- package/src/lib/{pickup-location-types.ts → booking/pickup-location-types.ts} +2 -2
- package/src/lib/{pricing.ts → booking/pricing.ts} +2 -2
- package/src/lib/booking/product-option-id.ts +35 -0
- package/src/lib/booking/source-metadata.ts +72 -7
- package/src/lib/booking/sunday-week.ts +14 -0
- package/src/lib/booking/trace-context.ts +62 -0
- package/src/lib/booking-api.ts +1793 -0
- package/src/lib/{constants.ts → booking-constants.ts} +11 -5
- package/src/lib/booking-types.ts +36 -0
- package/src/lib/currency.ts +38 -45
- package/src/lib/dap-descriptions.ts +50 -0
- package/src/lib/dap-itinerary-preview.ts +315 -0
- package/src/lib/dependent-add-on-api.ts +434 -0
- package/src/lib/env.ts +89 -5
- package/src/lib/firebase.ts +20 -0
- package/src/lib/job-application-api.ts +83 -0
- package/src/lib/manage-booking-embed-print.ts +16 -0
- package/src/lib/manage-booking-post-checkout.ts +68 -0
- package/src/lib/photo-dap-config.ts +228 -0
- package/src/lib/pickup/map-utils.ts +56 -0
- package/src/lib/pickup/marker-icons.ts +19 -0
- package/src/lib/product-descriptions.ts +66 -0
- package/src/lib/products-config.ts +73 -0
- package/src/providers/booking-dialog-provider.tsx +107 -38
- package/src/providers/bottom-sheet-provider.tsx +40 -0
- package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
- package/src/radius.css +5 -0
- package/src/spacing.css +7 -0
- package/src/strings/en.json +1774 -0
- package/src/strings/es.json +1573 -0
- package/src/strings/fr.json +1573 -0
- package/src/strings/index.js +23 -0
- package/src/text-style.css +97 -0
- package/src/types/fareharbor.d.ts +12 -0
- package/src/types/quiz.ts +59 -0
- package/src/utils/currency-converter.ts +101 -0
- package/src/components/BookingFlow.tsx +0 -2952
- package/src/components/LanguageSwitcher.tsx +0 -30
- package/src/components/PrivateShuttleBookingFlow.tsx +0 -2290
- package/src/components/ProductList.tsx +0 -78
- package/src/components/WhatsAppPhoneInput.tsx +0 -224
- package/src/components/index.ts +0 -31
- package/src/lib/api.ts +0 -801
- package/src/lib/booking-api-auth.ts +0 -9
- package/src/lib/checkout-breakdown.test.ts +0 -70
- package/src/types/google-maps.d.ts +0 -2
- /package/src/components/{CurrencySwitcher.tsx → booking/CurrencySwitcher.tsx} +0 -0
- /package/src/components/{ErrorBoundary.tsx → booking/ErrorBoundary.tsx} +0 -0
- /package/src/lib/{i18n → booking/i18n}/config.ts +0 -0
- /package/src/lib/{i18n → booking/i18n}/index.tsx +0 -0
- /package/src/lib/{marker-icons.ts → booking/marker-icons.ts} +0 -0
- /package/src/lib/{places-api.ts → booking/places-api.ts} +0 -0
- /package/src/lib/{theme.ts → booking/theme.ts} +0 -0
- /package/src/lib/{utils.ts → booking/utils.ts} +0 -0
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared constants for booking
|
|
2
|
+
* Shared constants for the booking flow.
|
|
3
|
+
* Aligns with TicketBooth availability season (Moraine Lake road).
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
// Date range for availability fetching
|
|
6
|
+
// Date range for availability fetching (YYYY-MM-DD)
|
|
6
7
|
export const EARLIEST_AVAILABILITY_DATE = new Date('2026-06-01');
|
|
7
8
|
export const LATEST_AVAILABILITY_DATE = new Date('2026-10-12');
|
|
8
9
|
|
|
10
|
+
// Initial fetch window (weeks) - smaller for faster first paint
|
|
11
|
+
export const INITIAL_FETCH_WEEKS = 2;
|
|
12
|
+
|
|
9
13
|
// Mini calendar date range (June 2026 - October 2026)
|
|
10
14
|
export const MINI_CALENDAR_START_MONTH = new Date(2026, 5, 1); // June 1, 2026 (month index 5 = June)
|
|
11
15
|
export const MINI_CALENDAR_END_MONTH = new Date(2026, 9, 1); // October 1, 2026 (month index 9 = October)
|
|
12
16
|
export const MINI_CALENDAR_START_DATE = new Date(2026, 5, 1); // June 1, 2026
|
|
13
17
|
export const MINI_CALENDAR_END_DATE = new Date(2026, 9, 12); // October 12, 2026 (inclusive)
|
|
14
18
|
|
|
15
|
-
//
|
|
16
|
-
export const
|
|
17
|
-
|
|
19
|
+
// Buffer weeks before/after visible range
|
|
20
|
+
export const VISIBLE_RANGE_BUFFER_WEEKS = 2;
|
|
21
|
+
|
|
22
|
+
/** 8am MDT Mar 11, 2025 – full booking flow becomes available at this moment (UTC). */
|
|
23
|
+
export const BOOKING_LAUNCH_AT = new Date('2026-03-11T14:00:00.000Z');
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the booking flow.
|
|
3
|
+
* Aligns with TicketBooth API responses where applicable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ProductDisplayConfig {
|
|
7
|
+
path: string;
|
|
8
|
+
slug: string;
|
|
9
|
+
imageIds: string[];
|
|
10
|
+
/** Optional: image IDs for the booking flow collage (4 images for the grid). Falls back to imageIds if not set. */
|
|
11
|
+
collageImageIds?: string[];
|
|
12
|
+
shortName: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
themePage: string;
|
|
15
|
+
mostPopular?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ProductConfig {
|
|
19
|
+
productId: string;
|
|
20
|
+
optionIds: string[];
|
|
21
|
+
/** STANDARD or PRIVATE_SHUTTLE - affects availability date format. Default STANDARD. */
|
|
22
|
+
productType?: 'STANDARD' | 'PRIVATE_SHUTTLE';
|
|
23
|
+
display: ProductDisplayConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProductsConfig {
|
|
27
|
+
version: string;
|
|
28
|
+
generatedAt: string;
|
|
29
|
+
companyId: string;
|
|
30
|
+
products: ProductConfig[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Primary option ID for a product (used for availability/reserve API calls) */
|
|
34
|
+
export function getPrimaryOptionId(product: ProductConfig): string {
|
|
35
|
+
return product.optionIds[0] ?? product.productId;
|
|
36
|
+
}
|
package/src/lib/currency.ts
CHANGED
|
@@ -1,88 +1,81 @@
|
|
|
1
|
-
import type { Currency } from '@/components/CurrencySwitcher';
|
|
2
|
-
import type { Locale } from '@/lib/i18n/config';
|
|
3
|
-
|
|
4
1
|
/**
|
|
5
|
-
*
|
|
6
|
-
* Keep in sync with backend ticketbooth-be/src/main/resources/pricing.json → companies.default.currencyRates.CAD
|
|
2
|
+
* Currency types supported by the booking system
|
|
7
3
|
*/
|
|
4
|
+
export type Currency = 'CAD' | 'USD' | 'EUR' | 'GBP' | 'AUD';
|
|
5
|
+
|
|
6
|
+
/** Default exchange rates from CAD to other currencies (fallback if API does not provide them). */
|
|
8
7
|
export const DEFAULT_EXCHANGE_RATES: Record<Currency, number> = {
|
|
9
|
-
CAD: 1.0,
|
|
8
|
+
CAD: 1.0,
|
|
10
9
|
USD: 0.74,
|
|
11
10
|
EUR: 0.62,
|
|
12
11
|
GBP: 0.53,
|
|
13
12
|
AUD: 1.04,
|
|
14
13
|
};
|
|
15
14
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
export function
|
|
23
|
-
return
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
15
|
+
/** Integer cents using half-up rounding. */
|
|
16
|
+
export function toMoneyCents(amount: number): number {
|
|
17
|
+
return Math.round(amount * 100);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when two money amounts differ by at most `toleranceCents` (default 1 cent). */
|
|
21
|
+
export function isMoneyDeltaNegligible(a: number, b: number, toleranceCents = 1): boolean {
|
|
22
|
+
return Math.abs(toMoneyCents(a) - toMoneyCents(b)) <= toleranceCents;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** For change-booking: send original total when delta is only rounding noise. */
|
|
26
|
+
export function reconcileChangeBookingProposedTotal(
|
|
27
|
+
proposedTotal: number,
|
|
28
|
+
originalTotal: number,
|
|
29
|
+
toleranceCents = 1
|
|
30
|
+
): number {
|
|
31
|
+
return isMoneyDeltaNegligible(proposedTotal, originalTotal, toleranceCents)
|
|
32
|
+
? originalTotal
|
|
33
|
+
: proposedTotal;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
/**
|
|
38
37
|
* Format a numeric amount in the given currency using Intl.NumberFormat.
|
|
39
38
|
* Falls back to a simple "123.45 CUR" string if formatting fails.
|
|
40
39
|
*/
|
|
41
|
-
// Currency symbols map - using industry standard ISO 4217 symbols
|
|
42
|
-
// When multiple dollar currencies are present, use distinguishing prefixes
|
|
43
40
|
const CURRENCY_SYMBOLS: Record<Currency, string> = {
|
|
44
|
-
CAD: 'C$',
|
|
45
|
-
USD: 'US$',
|
|
46
|
-
EUR: '€',
|
|
47
|
-
GBP: '£',
|
|
48
|
-
AUD: 'A$',
|
|
41
|
+
CAD: 'C$',
|
|
42
|
+
USD: 'US$',
|
|
43
|
+
EUR: '€',
|
|
44
|
+
GBP: '£',
|
|
45
|
+
AUD: 'A$',
|
|
49
46
|
};
|
|
50
47
|
|
|
51
48
|
/**
|
|
52
49
|
* Format a numeric amount in the given currency, respecting locale conventions.
|
|
53
50
|
* For French: uses comma as decimal separator and proper symbol placement (e.g., "109,99 €")
|
|
54
51
|
* For English: uses period as decimal separator (e.g., "C$109.99")
|
|
55
|
-
*
|
|
52
|
+
*
|
|
56
53
|
* @param amount The amount to format
|
|
57
54
|
* @param currency The currency code
|
|
58
55
|
* @param locale Optional locale ('en' or 'fr'). If not provided, defaults to 'en'
|
|
59
56
|
*/
|
|
60
|
-
export function formatCurrencyAmount(amount: number, currency: Currency, locale:
|
|
61
|
-
// Use explicit symbols and format number part with locale conventions
|
|
62
|
-
// This ensures USD shows as "US$" and AUD shows as "A$" correctly
|
|
57
|
+
export function formatCurrencyAmount(amount: number, currency: Currency, locale: 'en' | 'fr' = 'en'): string {
|
|
63
58
|
const symbol = CURRENCY_SYMBOLS[currency] || currency;
|
|
64
|
-
|
|
59
|
+
|
|
65
60
|
// Handle negative amounts: extract sign and format absolute value.
|
|
66
|
-
// Don't show minus for zero or tiny rounding errors (e.g. -0.00 or -0.0001).
|
|
67
61
|
const absoluteAmount = Math.abs(amount);
|
|
68
62
|
const isNegative = amount < -0.001;
|
|
69
|
-
|
|
63
|
+
|
|
70
64
|
// Format the number part with locale-appropriate separators
|
|
71
65
|
const numberFormatter = new Intl.NumberFormat(locale === 'fr' ? 'fr-CA' : 'en-CA', {
|
|
72
66
|
minimumFractionDigits: 2,
|
|
73
67
|
maximumFractionDigits: 2,
|
|
74
68
|
});
|
|
75
|
-
|
|
69
|
+
|
|
76
70
|
const formattedNumber = numberFormatter.format(absoluteAmount);
|
|
77
|
-
|
|
78
|
-
// For French: symbol after amount (e.g., "109,99 €"
|
|
79
|
-
// For English: symbol before amount (e.g., "
|
|
71
|
+
|
|
72
|
+
// For French: symbol after amount (e.g., "109,99 €")
|
|
73
|
+
// For English: symbol before amount (e.g., "C$109.99")
|
|
80
74
|
const sign = isNegative ? '-' : '';
|
|
81
|
-
|
|
75
|
+
|
|
82
76
|
if (locale === 'fr') {
|
|
83
77
|
return `${sign}${formattedNumber} ${symbol}`;
|
|
84
78
|
} else {
|
|
85
79
|
return `${sign}${symbol}${formattedNumber}`;
|
|
86
80
|
}
|
|
87
81
|
}
|
|
88
|
-
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy for dependent add-on (photo) booking dialog — src/data/dap-descriptions/{slug}.{locale}.json
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PhotoDapSlug } from '@/lib/photo-dap-config';
|
|
6
|
+
|
|
7
|
+
import sessionCouplesEn from '@/data/dap-descriptions/session-couples-families-friends.en.json';
|
|
8
|
+
import sessionElopementsEn from '@/data/dap-descriptions/session-elopements.en.json';
|
|
9
|
+
import sessionProposalsEn from '@/data/dap-descriptions/session-proposals.en.json';
|
|
10
|
+
|
|
11
|
+
import type { TourDescriptionSubsection } from '@/components/booking/TourDescription';
|
|
12
|
+
|
|
13
|
+
export type DapSectionContent = string | string[] | TourDescriptionSubsection[];
|
|
14
|
+
|
|
15
|
+
export interface DapDescriptionResult {
|
|
16
|
+
paragraphs: string[];
|
|
17
|
+
review?: { text: string; name: string };
|
|
18
|
+
sections: { title: string; content: DapSectionContent }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DapDescriptionData {
|
|
22
|
+
paragraphs: string[];
|
|
23
|
+
review?: { text: string; name: string };
|
|
24
|
+
sections: { title: string; content: DapSectionContent }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const EN: Record<PhotoDapSlug, DapDescriptionData> = {
|
|
28
|
+
'session-couples-families-friends': sessionCouplesEn as DapDescriptionData,
|
|
29
|
+
'session-elopements': sessionElopementsEn as DapDescriptionData,
|
|
30
|
+
'session-proposals': sessionProposalsEn as DapDescriptionData,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const BY_LOCALE: Record<string, Record<PhotoDapSlug, DapDescriptionData>> = {
|
|
34
|
+
en: EN,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function getDapDescription(
|
|
38
|
+
slug: PhotoDapSlug,
|
|
39
|
+
locale: string
|
|
40
|
+
): DapDescriptionResult | null {
|
|
41
|
+
const localeKey = locale.split('-')[0] ?? 'en';
|
|
42
|
+
const table = BY_LOCALE[localeKey] ?? BY_LOCALE.en;
|
|
43
|
+
const data = table[slug];
|
|
44
|
+
if (!data) return null;
|
|
45
|
+
return {
|
|
46
|
+
paragraphs: data.paragraphs ?? [],
|
|
47
|
+
review: data.review,
|
|
48
|
+
sections: data.sections ?? [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for showing primary-booking itinerary in the DAP flow without last-name lookup.
|
|
3
|
+
* Real steps come from GET /1/dependent-add-ons/availability when the API includes
|
|
4
|
+
* `data.itineraryDisplay` (or `data.primaryItineraryDisplay`) — see dependent-add-on-api.ts.
|
|
5
|
+
*
|
|
6
|
+
* Photo-session preview row placement uses the selected slot’s ISO times plus each
|
|
7
|
+
* `arrive` → next `depart` on-site window (not string “last moraine mention”, which
|
|
8
|
+
* incorrectly anchored after “Depart Moraine Lake”).
|
|
9
|
+
*
|
|
10
|
+
* Availability: [filterDapSlotsToPrimaryItineraryWindows] drops any slot whose
|
|
11
|
+
* [slotStart, slotEnd] is not fully contained in at least one such window (defensive
|
|
12
|
+
* client filter; TicketBooth should enforce the same when generating offerings).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ItineraryDisplayStep, ItineraryStepType } from '@/lib/booking-api';
|
|
16
|
+
import { ItineraryStepType as StepTypeConst } from '@/lib/booking-api';
|
|
17
|
+
import { getStepLabel, type TranslationFn } from '@/lib/booking/itinerary-labels';
|
|
18
|
+
|
|
19
|
+
const ITINERARY_STEP_TYPES: readonly string[] = Object.values(StepTypeConst);
|
|
20
|
+
|
|
21
|
+
/** Clock / rounding tolerance when comparing itinerary strings to slot ISO instants. */
|
|
22
|
+
const TIME_MATCH_TOLERANCE_MS = 90_000;
|
|
23
|
+
|
|
24
|
+
export type DapItineraryStep = {
|
|
25
|
+
stepType: string;
|
|
26
|
+
time?: string | null;
|
|
27
|
+
place?: string | null;
|
|
28
|
+
label?: string | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type DapPhotoSessionInsertOptions = {
|
|
32
|
+
slotStartIso: string;
|
|
33
|
+
slotEndIso: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function pickStr(v: unknown): string | undefined {
|
|
37
|
+
return typeof v === 'string' && v.trim() ? v.trim() : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeDapItinerarySteps(raw: unknown): DapItineraryStep[] {
|
|
41
|
+
if (!Array.isArray(raw)) return [];
|
|
42
|
+
const out: DapItineraryStep[] = [];
|
|
43
|
+
for (const item of raw) {
|
|
44
|
+
if (!item || typeof item !== 'object') continue;
|
|
45
|
+
const r = item as Record<string, unknown>;
|
|
46
|
+
const stepType = pickStr(r.stepType ?? r.step_type) ?? 'other';
|
|
47
|
+
out.push({
|
|
48
|
+
stepType,
|
|
49
|
+
time: pickStr(r.time) ?? null,
|
|
50
|
+
place: pickStr(r.place) ?? null,
|
|
51
|
+
label: pickStr(r.label) ?? null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normStepType(s: DapItineraryStep): string {
|
|
58
|
+
return (s.stepType ?? '').toLowerCase().trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Plain-text blob for matching place names in fallbacks */
|
|
62
|
+
function stepBlob(s: DapItineraryStep): string {
|
|
63
|
+
return `${s.stepType} ${s.place ?? ''} ${s.label ?? ''} ${s.time ?? ''}`.toLowerCase();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a step’s display `time` onto the trip’s local calendar day (from slot start).
|
|
68
|
+
* Supports ISO-ish strings, 12h (“4:30 AM”), and 24h (“14:30”).
|
|
69
|
+
*/
|
|
70
|
+
function stepTimeToMs(
|
|
71
|
+
step: DapItineraryStep,
|
|
72
|
+
tripDayRef: Date
|
|
73
|
+
): number | null {
|
|
74
|
+
const raw = step.time?.trim();
|
|
75
|
+
if (!raw) return null;
|
|
76
|
+
const cleaned = raw.replace(/^at\s+/i, '').trim();
|
|
77
|
+
if (!cleaned) return null;
|
|
78
|
+
|
|
79
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(cleaned) || cleaned.includes('T')) {
|
|
80
|
+
const ms = Date.parse(cleaned);
|
|
81
|
+
return Number.isNaN(ms) ? null : ms;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const y = tripDayRef.getFullYear();
|
|
85
|
+
const mo = tripDayRef.getMonth();
|
|
86
|
+
const d = tripDayRef.getDate();
|
|
87
|
+
|
|
88
|
+
const m12 = cleaned.match(/^(\d{1,2})(?::(\d{2}))?\s*(AM|PM)\s*$/i);
|
|
89
|
+
if (m12) {
|
|
90
|
+
let h = parseInt(m12[1], 10);
|
|
91
|
+
const min = m12[2] ? parseInt(m12[2], 10) : 0;
|
|
92
|
+
const ap = m12[3].toUpperCase();
|
|
93
|
+
if (ap === 'PM' && h !== 12) h += 12;
|
|
94
|
+
if (ap === 'AM' && h === 12) h = 0;
|
|
95
|
+
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
|
96
|
+
return new Date(y, mo, d, h, min, 0, 0).getTime();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const m24 = cleaned.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
|
100
|
+
if (m24) {
|
|
101
|
+
const h = parseInt(m24[1], 10);
|
|
102
|
+
const min = parseInt(m24[2], 10);
|
|
103
|
+
if (h >= 0 && h <= 23 && min >= 0 && min <= 59) {
|
|
104
|
+
return new Date(y, mo, d, h, min, 0, 0).getTime();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const trial = Date.parse(
|
|
109
|
+
`${tripDayRef.toLocaleDateString('en-US', {
|
|
110
|
+
month: 'short',
|
|
111
|
+
day: 'numeric',
|
|
112
|
+
year: 'numeric',
|
|
113
|
+
})} ${cleaned}`
|
|
114
|
+
);
|
|
115
|
+
return Number.isNaN(trial) ? null : trial;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type ArriveDepartWindow = {
|
|
119
|
+
arriveIndex: number;
|
|
120
|
+
departIndex: number;
|
|
121
|
+
startMs: number;
|
|
122
|
+
endMs: number;
|
|
123
|
+
blob: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function listArriveDepartWindows(
|
|
127
|
+
steps: DapItineraryStep[],
|
|
128
|
+
tripDayRef: Date
|
|
129
|
+
): ArriveDepartWindow[] {
|
|
130
|
+
const out: ArriveDepartWindow[] = [];
|
|
131
|
+
for (let i = 0; i < steps.length; i++) {
|
|
132
|
+
if (normStepType(steps[i]) !== 'arrive') continue;
|
|
133
|
+
const startMs = stepTimeToMs(steps[i], tripDayRef);
|
|
134
|
+
let j = i + 1;
|
|
135
|
+
while (j < steps.length && normStepType(steps[j]) !== 'depart') {
|
|
136
|
+
j += 1;
|
|
137
|
+
}
|
|
138
|
+
if (j >= steps.length) continue;
|
|
139
|
+
const endMs = stepTimeToMs(steps[j], tripDayRef);
|
|
140
|
+
if (startMs == null || endMs == null) continue;
|
|
141
|
+
if (endMs < startMs) continue;
|
|
142
|
+
out.push({
|
|
143
|
+
arriveIndex: i,
|
|
144
|
+
departIndex: j,
|
|
145
|
+
startMs,
|
|
146
|
+
endMs,
|
|
147
|
+
blob: `${stepBlob(steps[i])} ${stepBlob(steps[j])}`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function slotFitsWindow(
|
|
154
|
+
slotStartMs: number,
|
|
155
|
+
slotEndMs: number,
|
|
156
|
+
w: ArriveDepartWindow
|
|
157
|
+
): boolean {
|
|
158
|
+
return (
|
|
159
|
+
slotStartMs >= w.startMs - TIME_MATCH_TOLERANCE_MS &&
|
|
160
|
+
slotEndMs <= w.endMs + TIME_MATCH_TOLERANCE_MS
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function slotWindowOverlapMs(
|
|
165
|
+
slotStartMs: number,
|
|
166
|
+
slotEndMs: number,
|
|
167
|
+
w: ArriveDepartWindow
|
|
168
|
+
): number {
|
|
169
|
+
const lo = Math.max(slotStartMs, w.startMs);
|
|
170
|
+
const hi = Math.min(slotEndMs, w.endMs);
|
|
171
|
+
return Math.max(0, hi - lo);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* When no arrive/depart windows parse, place the row before the first `depart` after
|
|
176
|
+
* the first `arrive` at Moraine (or first arrive→depart pair, then pickup heuristic).
|
|
177
|
+
*/
|
|
178
|
+
function fallbackInsertDepartIndex(steps: DapItineraryStep[]): number {
|
|
179
|
+
for (let i = 0; i < steps.length; i++) {
|
|
180
|
+
if (normStepType(steps[i]) !== 'arrive') continue;
|
|
181
|
+
if (!stepBlob(steps[i]).includes('moraine')) continue;
|
|
182
|
+
for (let j = i + 1; j < steps.length; j++) {
|
|
183
|
+
if (normStepType(steps[j]) === 'depart') return j;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const firstArrive = steps.findIndex((s) => normStepType(s) === 'arrive');
|
|
188
|
+
if (firstArrive >= 0) {
|
|
189
|
+
const departAfter = steps.findIndex(
|
|
190
|
+
(s, idx) => idx > firstArrive && normStepType(s) === 'depart'
|
|
191
|
+
);
|
|
192
|
+
if (departAfter >= 0) return departAfter;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const pickupIdx = steps.findIndex((s) => normStepType(s) === 'pickup');
|
|
196
|
+
if (pickupIdx >= 0) return Math.min(pickupIdx + 1, steps.length);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Index in `steps` **before which** the photo row is rendered (i.e. immediately after
|
|
202
|
+
* the preceding step, typically right after “Arrive at …” and before “Depart …”).
|
|
203
|
+
*/
|
|
204
|
+
export function getPhotoSessionInsertPosition(
|
|
205
|
+
steps: DapItineraryStep[],
|
|
206
|
+
options?: DapPhotoSessionInsertOptions | null
|
|
207
|
+
): number {
|
|
208
|
+
if (steps.length === 0) return 0;
|
|
209
|
+
|
|
210
|
+
const slotStartMs = options?.slotStartIso ? Date.parse(options.slotStartIso) : NaN;
|
|
211
|
+
const slotEndMs = options?.slotEndIso ? Date.parse(options.slotEndIso) : NaN;
|
|
212
|
+
const hasValidSlot =
|
|
213
|
+
!Number.isNaN(slotStartMs) &&
|
|
214
|
+
!Number.isNaN(slotEndMs) &&
|
|
215
|
+
slotEndMs >= slotStartMs;
|
|
216
|
+
|
|
217
|
+
const tripDayRef = hasValidSlot ? new Date(slotStartMs) : new Date();
|
|
218
|
+
const windows = listArriveDepartWindows(steps, tripDayRef);
|
|
219
|
+
|
|
220
|
+
if (hasValidSlot && windows.length > 0) {
|
|
221
|
+
for (const w of windows) {
|
|
222
|
+
if (slotFitsWindow(slotStartMs, slotEndMs, w)) {
|
|
223
|
+
return w.departIndex;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let bestDepart = windows[windows.length - 1].departIndex;
|
|
228
|
+
let bestOverlap = -1;
|
|
229
|
+
for (const w of windows) {
|
|
230
|
+
const ov = slotWindowOverlapMs(slotStartMs, slotEndMs, w);
|
|
231
|
+
if (ov > bestOverlap) {
|
|
232
|
+
bestOverlap = ov;
|
|
233
|
+
bestDepart = w.departIndex;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (bestOverlap > 0) {
|
|
237
|
+
return bestDepart;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (let wi = windows.length - 1; wi >= 0; wi--) {
|
|
241
|
+
if (windows[wi].blob.includes('moraine')) {
|
|
242
|
+
return windows[wi].departIndex;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return windows[windows.length - 1].departIndex;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (windows.length > 0) {
|
|
249
|
+
for (let wi = windows.length - 1; wi >= 0; wi--) {
|
|
250
|
+
if (windows[wi].blob.includes('moraine')) {
|
|
251
|
+
return windows[wi].departIndex;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return windows[windows.length - 1].departIndex;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return fallbackInsertDepartIndex(steps);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Keeps only slots that fall entirely inside an on-site window: each `arrive` step paired
|
|
262
|
+
* with the next `depart`. Uses the same time parsing and tolerance as preview insertion.
|
|
263
|
+
*
|
|
264
|
+
* If no windows can be parsed from `steps`, returns `slots` unchanged (do not hide offers).
|
|
265
|
+
*/
|
|
266
|
+
export function filterDapSlotsToPrimaryItineraryWindows<
|
|
267
|
+
T extends { slotStart: string; slotEnd: string },
|
|
268
|
+
>(slots: T[], steps: DapItineraryStep[]): T[] {
|
|
269
|
+
if (steps.length === 0 || slots.length === 0) return slots;
|
|
270
|
+
|
|
271
|
+
return slots.filter((slot) => {
|
|
272
|
+
const slotStartMs = Date.parse(slot.slotStart);
|
|
273
|
+
const slotEndMs = Date.parse(slot.slotEnd);
|
|
274
|
+
if (
|
|
275
|
+
Number.isNaN(slotStartMs) ||
|
|
276
|
+
Number.isNaN(slotEndMs) ||
|
|
277
|
+
slotEndMs < slotStartMs
|
|
278
|
+
) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const tripDayRef = new Date(slotStartMs);
|
|
283
|
+
const windows = listArriveDepartWindows(steps, tripDayRef);
|
|
284
|
+
if (windows.length === 0) return true;
|
|
285
|
+
|
|
286
|
+
return windows.some((w) => slotFitsWindow(slotStartMs, slotEndMs, w));
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function formatDapItineraryStepLabel(step: DapItineraryStep, t: TranslationFn): string {
|
|
291
|
+
if (step.label?.trim()) return step.label.trim();
|
|
292
|
+
return getStepLabel(
|
|
293
|
+
{
|
|
294
|
+
stepType: step.stepType || 'other',
|
|
295
|
+
place: step.place ?? '',
|
|
296
|
+
time: step.time ?? '',
|
|
297
|
+
},
|
|
298
|
+
t
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Map DAP availability steps to the same shape as main booking [ItineraryBox] uses. */
|
|
303
|
+
export function dapItineraryStepsToDisplay(steps: DapItineraryStep[]): ItineraryDisplayStep[] {
|
|
304
|
+
return steps.map((s) => {
|
|
305
|
+
const raw = (s.stepType ?? '').toLowerCase().trim();
|
|
306
|
+
const stepType = (ITINERARY_STEP_TYPES.includes(raw)
|
|
307
|
+
? raw
|
|
308
|
+
: 'other') as ItineraryStepType;
|
|
309
|
+
return {
|
|
310
|
+
stepType,
|
|
311
|
+
time: s.time?.trim() ?? '',
|
|
312
|
+
place: s.place ?? undefined,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
}
|